deploy: add ebay marketplace notifications

This commit is contained in:
DMleadgen 2026-04-01 14:41:34 -06:00
parent 74a23ae2af
commit 975fc06136
Signed by: matt
GPG key ID: C2720CF8CD701894
6 changed files with 1038 additions and 152 deletions

View file

@ -1,28 +1,72 @@
NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app
NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app
# Current Rocky Mountain Vending staging env contract.
# Fill these in through Coolify-managed environment variables only.
CONVEX_URL=
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
CONVEX_TENANT_SLUG=rocky_mountain_vending
CONVEX_TENANT_NAME=Rocky Mountain Vending
ADMIN_UI_ENABLED=true
ADMIN_API_TOKEN=
ADMIN_EMAIL=
PHONE_AGENT_INTERNAL_TOKEN=
PHONE_CALL_SUMMARY_FROM_EMAIL=
USESEND_API_KEY=
USESEND_BASE_URL=
USESEND_FROM_EMAIL=info@rockymountainvending.com
CONTACT_FORM_TO_EMAIL=info@rockymountainvending.com
GHL_API_TOKEN=
GHL_LOCATION_ID=
# Core site
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
NEXT_PUBLIC_CONVEX_URL=
# Voice and chat
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app
XAI_API_KEY=
XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning
VOICE_ASSISTANT_SITE_URL=https://rockymountainvending.com
PHONE_AGENT_INTERNAL_TOKEN=
NEXT_PUBLIC_CALL_PHONE_DISPLAY=(435) 233-9668
NEXT_PUBLIC_CALL_PHONE_E164=+14352339668
NEXT_PUBLIC_SMS_PHONE_DISPLAY=(435) 233-9668
NEXT_PUBLIC_SMS_PHONE_E164=+14352339668
NEXT_PUBLIC_MANUALS_BASE_URL=
NEXT_PUBLIC_THUMBNAILS_BASE_URL=
VOICE_RECORDING_ENABLED=false
VOICE_RECORDING_BUCKET=
VOICE_RECORDING_ENDPOINT=
VOICE_RECORDING_PUBLIC_BASE_URL=
VOICE_RECORDING_ACCESS_KEY_ID=
VOICE_RECORDING_SECRET_ACCESS_KEY=
VOICE_RECORDING_REGION=auto
# Admin and auth
ADMIN_EMAIL=
ADMIN_PASSWORD=
RESEND_API_KEY=
PHONE_CALL_SUMMARY_FROM_EMAIL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Stripe
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
# GHL handoff + sync (API-only mode)
GHL_PRIVATE_INTEGRATION_TOKEN=
GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG
GHL_API_VERSION=2021-07-28
GHL_API_BASE_URL=https://services.leadconnectorhq.com
GHL_SYNC_CRON_TOKEN=
GHL_SYNC_INTERVAL_MINUTES=15
GHL_SYNC_PAGE_SIZE=100
# Optional/deprecated in API-only mode
GHL_WEBHOOK_SHARED_SECRET=
GHL_CONTACT_WEBHOOK_URL=
GHL_REQUEST_MACHINE_WEBHOOK_URL=
# eBay API credentials for manuals-side fallback
EBAY_APP_ID=
EBAY_DEV_ID=
EBAY_CERT_ID=
EBAY_SANDBOX_TOKEN=
EBAY_AFFILIATE_CAMPAIGN_ID=
# eBay marketplace account deletion notifications
# Use the exact public HTTPS endpoint that eBay validates in the developer portal.
EBAY_NOTIFICATION_ENDPOINT=https://rmv.abundancepartners.app/api/ebay/notifications
EBAY_NOTIFICATION_VERIFICATION_TOKEN=
EBAY_NOTIFICATION_APP_ID=
EBAY_NOTIFICATION_CERT_ID=
EBAY_NOTIFICATION_API_BASE_URL=https://api.ebay.com
EBAY_NOTIFICATION_SCOPE=https://api.ebay.com/oauth/api_scope

View file

@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from "next/server"
import {
computeEbayChallengeResponse,
getEbayNotificationEndpoint,
getEbayNotificationVerificationToken,
verifyEbayNotificationSignature,
} from "@/lib/ebay-notifications"
export const runtime = "nodejs"
type EbayNotificationPayload = {
metadata?: {
topic?: string
schemaVersion?: string
deprecated?: boolean
}
notification?: {
notificationId?: string
eventDate?: string
publishDate?: string
publishAttemptCount?: number
data?: {
username?: string
userId?: string
eiasToken?: string
}
}
}
function parseNotificationBody(rawBody: string) {
if (!rawBody.trim()) {
return null
}
try {
return JSON.parse(rawBody) as EbayNotificationPayload
} catch {
return null
}
}
function getChallengeCode(request: NextRequest) {
return (
request.nextUrl.searchParams.get("challenge_code") ||
request.nextUrl.searchParams.get("challengeCode") ||
""
).trim()
}
export async function GET(request: NextRequest) {
const challengeCode = getChallengeCode(request)
if (!challengeCode) {
return NextResponse.json({ error: "Missing challenge_code query parameter." }, { status: 400 })
}
const verificationToken = getEbayNotificationVerificationToken()
if (!verificationToken) {
return NextResponse.json(
{ error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." },
{ status: 500 },
)
}
const endpoint = getEbayNotificationEndpoint(request.url)
if (!endpoint) {
return NextResponse.json({ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." }, { status: 500 })
}
const challengeResponse = computeEbayChallengeResponse({
challengeCode,
endpoint,
verificationToken,
})
return NextResponse.json(
{ challengeResponse },
{
headers: {
"Cache-Control": "no-store",
},
},
)
}
export async function POST(request: NextRequest) {
const body = await request.text()
const signatureHeader = request.headers.get("x-ebay-signature")
try {
const verification = await verifyEbayNotificationSignature({
body,
signatureHeader,
})
if (!verification.verified) {
if (verification.reason === "Notification verification credentials are not configured.") {
console.warn("[ebay/notifications] accepted notification without signature verification", {
reason: verification.reason,
})
const payload = parseNotificationBody(body)
const notification = payload?.notification
console.info("[ebay/notifications] accepted notification without verification", {
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
})
return new NextResponse(null, { status: 204 })
}
console.warn("[ebay/notifications] signature rejected", {
reason: verification.reason,
})
return NextResponse.json({ error: verification.reason }, { status: 412 })
}
const payload = parseNotificationBody(body)
const notification = payload?.notification
console.info("[ebay/notifications] accepted notification", {
keyId: verification.keyId,
topic: payload?.metadata?.topic || "unknown",
notificationId: notification?.notificationId || "unknown",
publishAttemptCount: notification?.publishAttemptCount ?? null,
})
return new NextResponse(null, { status: 204 })
} catch (error) {
console.error("[ebay/notifications] failed to process notification", {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to verify eBay notification.",
},
{ status: 500 },
)
}
}

View file

@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* eBay API Proxy Route
* Proxies requests to eBay Finding API to avoid CORS issues
*/
interface eBaySearchParams {
keywords: string
categoryId?: string
sortOrder?: string
maxResults?: number
}
interface eBaySearchResult {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}
type MaybeArray<T> = T | T[]
// 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 || ''
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),
}
}
export async function GET(request: NextRequest) {
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)
if (!keywords) {
return NextResponse.json({ error: 'Keywords parameter is required' }, { status: 400 })
}
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 }
)
}
// 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 response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('eBay API error:', response.status, errorText)
return NextResponse.json(
{ error: `eBay API error: ${response.status}` },
{ status: response.status }
)
}
const data = await response.json()
// Parse eBay API response
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
if (!findItemsAdvancedResponse) {
return NextResponse.json([])
}
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
if (!searchResult || !searchResult.item || searchResult.item.length === 0) {
return NextResponse.json([])
}
const items = Array.isArray(searchResult.item) ? searchResult.item : [searchResult.item]
const results: eBaySearchResult[] = items.map((item: any) => normalizeItem(item))
return NextResponse.json(results)
} catch (error) {
console.error('Error fetching from eBay API:', error)
return NextResponse.json(
{ error: 'Failed to fetch products from eBay' },
{ status: 500 }
)
}
}

View file

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { getTopPartsForManual, type PartForPage } from '@/lib/parts-lookup'
@ -14,30 +14,60 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
const [parts, setParts] = useState<PartForPage[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isSearching, setIsSearching] = useState(false)
useEffect(() => {
async function loadParts() {
setIsLoading(true)
setIsSearching(false)
setError(null)
try {
const topParts = await getTopPartsForManual(manualFilename, 5)
setParts(topParts)
} catch (err) {
console.error('Error loading parts:', err)
setError('Could not load parts')
} finally {
setIsLoading(false)
}
}
if (manualFilename) {
loadParts()
const loadParts = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const result = await getTopPartsForManual(manualFilename, 5)
setParts(result.parts)
setError(result.error ?? null)
} catch (err) {
console.error('Error loading parts:', err)
setParts([])
setError('Could not load parts')
} finally {
setIsLoading(false)
}
}, [manualFilename])
useEffect(() => {
if (manualFilename) {
void loadParts()
}
}, [loadParts, manualFilename])
const hasListings = parts.some((part) => part.ebayListings.length > 0)
const renderStatusCard = (title: string, message: string) => (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
</div>
<div className="flex flex-1 flex-col items-center justify-center px-3 py-4 text-center">
<AlertCircle className="h-5 w-5 text-yellow-700 dark:text-yellow-300 mb-2" />
<p className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">{title}</p>
<p className="mt-1 text-[11px] leading-relaxed text-yellow-900/70 dark:text-yellow-100/70">
{message}
</p>
<Button
variant="outline"
size="sm"
onClick={() => void loadParts()}
className="mt-3 h-8 text-[11px] border-yellow-300/60 text-yellow-900 hover:bg-yellow-100 dark:border-yellow-700/60 dark:text-yellow-100 dark:hover:bg-yellow-900/40"
>
Retry
</Button>
</div>
</div>
)
if (isLoading) {
return (
<div className={`flex flex-col h-full ${className}`}>
@ -57,7 +87,16 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
)
}
if (error || parts.length === 0) {
if (error && !hasListings) {
return renderStatusCard(
'eBay unavailable',
error.includes('eBay API not configured')
? 'Set EBAY_APP_ID in the app environment so live listings can load.'
: error,
)
}
if (parts.length === 0 || !hasListings) {
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
@ -88,6 +127,24 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
{error && (
<div className="rounded-md border border-yellow-300/40 bg-yellow-50/80 px-3 py-2 text-[11px] text-yellow-900 dark:border-yellow-700/40 dark:bg-yellow-950/40 dark:text-yellow-100">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium">
Live eBay listings are unavailable right now.
</p>
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{error.includes('eBay API not configured')
? 'Set EBAY_APP_ID in the app environment, then reload the panel.'
: error}
</p>
</div>
</div>
</div>
)}
{parts.map((part, index) => (
<div
key={`${part.partNumber}-${index}`}
@ -108,7 +165,7 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
{/* eBay Listings */}
{part.ebayListings.length > 0 && (
<div className="space-y-1.5">
{part.ebayListings.slice(0, 2).map((listing, listingIndex) => (
{part.ebayListings.slice(0, 2).map((listing) => (
<a
key={listing.itemId}
href={listing.affiliateLink}
@ -171,7 +228,7 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
<div className="pt-1">
<Button
variant="ghost"
size="xs"
size="sm"
className="w-full text-[10px] text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100"
>
View All {part.ebayListings.length} Listings
@ -197,4 +254,3 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
</div>
)
}

339
lib/ebay-notifications.ts Normal file
View file

@ -0,0 +1,339 @@
import { createHash, createPublicKey, createVerify } from "node:crypto"
import type { NextRequest } from "next/server"
const DEFAULT_EBAY_API_BASE_URL = "https://api.ebay.com"
const DEFAULT_EBAY_NOTIFICATION_SCOPE = "https://api.ebay.com/oauth/api_scope"
const DEFAULT_EBAY_NOTIFICATION_ENDPOINT = "https://rmv.abundancepartners.app/api/ebay/notifications"
const DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN = "rmv_ebay_notifications_2026_04_01_test_7c4f1a9d"
const PUBLIC_KEY_CACHE_TTL = 60 * 60 * 1000
const ACCESS_TOKEN_CACHE_TTL_SKEW = 60 * 1000
type CachedAccessToken = {
token: string
expiresAt: number
}
type CachedPublicKey = {
algorithm: string
digest: string
key: string
expiresAt: number
}
export type EbayNotificationSignature = {
algorithm?: string
digest?: string
keyId: string
signature: string
}
type EbayPublicKeyResponse = {
algorithm?: string
digest?: string
key?: string
}
type VerificationResult =
| {
verified: true
keyId: string
}
| {
verified: false
reason: string
}
const accessTokenCache = new Map<string, CachedAccessToken>()
const publicKeyCache = new Map<string, CachedPublicKey>()
function getOptionalEnv(name: string) {
const value = process.env[name]
return typeof value === "string" && value.trim() ? value.trim() : ""
}
function stripTrailingSlash(value: string) {
return value.replace(/\/+$/, "")
}
function normalizeEndpointFromRequest(requestUrl?: string) {
if (!requestUrl) {
return ""
}
try {
const url = new URL(requestUrl)
return `${url.origin}${url.pathname}`.replace(/\/+$/, "")
} catch {
return requestUrl.split(/[?#]/)[0]?.replace(/\/+$/, "") || ""
}
}
export function getEbayNotificationEndpoint(requestUrl?: string) {
const configuredEndpoint = getOptionalEnv("EBAY_NOTIFICATION_ENDPOINT")
if (configuredEndpoint) {
return stripTrailingSlash(configuredEndpoint)
}
if (DEFAULT_EBAY_NOTIFICATION_ENDPOINT) {
return stripTrailingSlash(DEFAULT_EBAY_NOTIFICATION_ENDPOINT)
}
return normalizeEndpointFromRequest(requestUrl)
}
export function getEbayNotificationVerificationToken() {
return getOptionalEnv("EBAY_NOTIFICATION_VERIFICATION_TOKEN") || DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN
}
export function computeEbayChallengeResponse(args: {
challengeCode: string
endpoint: string
verificationToken: string
}) {
const hash = createHash("sha256")
hash.update(args.challengeCode)
hash.update(args.verificationToken)
hash.update(args.endpoint)
return hash.digest("hex")
}
function base64DecodeText(rawValue: string) {
const normalized = rawValue.trim().replace(/-/g, "+").replace(/_/g, "/")
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4)
try {
return Buffer.from(padded, "base64").toString("utf8").trim()
} catch {
return ""
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
function firstString(...values: unknown[]) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim()
}
}
return ""
}
export function parseEbaySignatureHeader(rawHeader: string | null): EbayNotificationSignature | null {
const trimmed = typeof rawHeader === "string" ? rawHeader.trim() : ""
if (!trimmed) {
return null
}
const candidates = [trimmed, base64DecodeText(trimmed)].filter(Boolean)
for (const candidate of candidates) {
const parsed = (() => {
try {
return JSON.parse(candidate) as unknown
} catch {
return null
}
})()
const parsedRecord = asRecord(parsed)
if (parsedRecord) {
const keyId = firstString(parsedRecord.keyId, parsedRecord.kid, parsedRecord.key_id, parsedRecord.publicKeyId)
const signature = firstString(parsedRecord.signature, parsedRecord.sig, parsedRecord.value)
const algorithm = firstString(parsedRecord.algorithm, parsedRecord.alg)
const digest = firstString(parsedRecord.digest, parsedRecord.hash)
if (keyId && signature) {
return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined }
}
if (keyId) {
return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined }
}
}
}
const decoded = base64DecodeText(trimmed)
if (decoded) {
const keyId = decoded
return { keyId, signature: "" }
}
return null
}
function normalizeDigest(digest?: string) {
switch ((digest || "").trim().toLowerCase()) {
case "sha1":
return "sha1"
case "sha256":
return "sha256"
case "sha384":
return "sha384"
case "sha512":
return "sha512"
default:
return "sha1"
}
}
function normalizePublicKeyPem(value: string) {
const trimmed = value.trim()
if (!trimmed) {
return ""
}
if (trimmed.includes("BEGIN PUBLIC KEY")) {
return trimmed
}
const compact = trimmed.replace(/\s+/g, "")
const chunks = compact.match(/.{1,64}/g) || [compact]
return `-----BEGIN PUBLIC KEY-----\n${chunks.join("\n")}\n-----END PUBLIC KEY-----`
}
async function getEbayAccessToken() {
const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID")
const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID")
const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL)
const scope = getOptionalEnv("EBAY_NOTIFICATION_SCOPE") || DEFAULT_EBAY_NOTIFICATION_SCOPE
if (!clientId || !clientSecret) {
throw new Error("eBay notification verification requires EBAY_NOTIFICATION_APP_ID and EBAY_NOTIFICATION_CERT_ID.")
}
const cacheKey = `${apiBaseUrl}|${clientId}|${clientSecret}|${scope}`
const cached = accessTokenCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
return cached.token
}
const tokenUrl = `${apiBaseUrl}/identity/v1/oauth2/token`
const body = new URLSearchParams()
body.set("grant_type", "client_credentials")
body.set("scope", scope)
const authValue = Buffer.from(`${clientId}:${clientSecret}`).toString("base64")
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
Authorization: `Basic ${authValue}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body,
})
if (!response.ok) {
const details = await response.text().catch(() => "")
throw new Error(
`Failed to fetch eBay access token (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`,
)
}
const payload = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null
const token = typeof payload?.access_token === "string" ? payload.access_token : ""
const expiresIn = typeof payload?.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600
if (!token) {
throw new Error("eBay access token response did not include an access_token value.")
}
accessTokenCache.set(cacheKey, {
token,
expiresAt: Date.now() + Math.max(expiresIn * 1000 - ACCESS_TOKEN_CACHE_TTL_SKEW, 60_000),
})
return token
}
async function getEbayPublicKey(keyId: string) {
const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL)
const cacheKey = `${apiBaseUrl}|${keyId}`
const cached = publicKeyCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
return cached
}
const accessToken = await getEbayAccessToken()
const response = await fetch(`${apiBaseUrl}/commerce/notification/v1/public_key/${encodeURIComponent(keyId)}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
})
if (!response.ok) {
const details = await response.text().catch(() => "")
throw new Error(
`Failed to fetch eBay public key (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`,
)
}
const payload = (await response.json().catch(() => null)) as EbayPublicKeyResponse | null
const key = typeof payload?.key === "string" ? normalizePublicKeyPem(payload.key) : ""
const algorithm = typeof payload?.algorithm === "string" && payload.algorithm.trim() ? payload.algorithm.trim() : ""
const digest = typeof payload?.digest === "string" && payload.digest.trim() ? payload.digest.trim() : ""
if (!key) {
throw new Error("eBay public key response did not include a valid key.")
}
const cachedValue = {
algorithm,
digest,
key,
expiresAt: Date.now() + PUBLIC_KEY_CACHE_TTL,
}
publicKeyCache.set(cacheKey, cachedValue)
return cachedValue
}
export async function verifyEbayNotificationSignature(args: {
body: string
signatureHeader: string | null
}): Promise<VerificationResult> {
const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID")
const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID")
if (!clientId || !clientSecret) {
return {
verified: false,
reason: "Notification verification credentials are not configured.",
}
}
const signature = parseEbaySignatureHeader(args.signatureHeader)
if (!signature) {
return { verified: false, reason: "Missing or invalid X-EBAY-SIGNATURE header." }
}
if (!signature.keyId) {
return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a key id." }
}
if (!signature.signature) {
return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a signature value." }
}
const publicKey = await getEbayPublicKey(signature.keyId)
const verifier = createVerify(normalizeDigest(signature.digest || publicKey.digest))
verifier.update(args.body, "utf8")
verifier.end()
const verified = verifier.verify(createPublicKey(publicKey.key), signature.signature, "base64")
if (!verified) {
return { verified: false, reason: "eBay notification signature verification failed." }
}
return { verified: true, keyId: signature.keyId }
}

View file

@ -1,12 +1,12 @@
/**
* 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
* goes through the server route so credentials never reach the browser.
*/
import { ebayClient } from './ebay-api'
interface PartForPage {
export interface PartForPage {
partNumber: string
description: string
ebayListings: Array<{
@ -32,8 +32,25 @@ 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[]
error?: string
}
// Cache for eBay search results
const ebaySearchCache = new Map<string, { results: any[]; timestamp: number }>()
const ebaySearchCache = new Map<string, { results: EbaySearchResult[]; timestamp: number }>()
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
let manualPartsCache: ManualPartsLookup | null = null
@ -83,56 +100,227 @@ async function loadPartsData(): Promise<{
}
/**
* Search eBay for parts with caching
* Search eBay for parts with caching.
* This calls the server route so the app never needs direct eBay credentials
* in client code.
*/
async function searchEBayForParts(partNumber: string, description?: string, manufacturer?: string): Promise<any[]> {
async function searchEBayForParts(
partNumber: string,
description?: string,
manufacturer?: string,
): Promise<EbaySearchResponse> {
const cacheKey = `parts:${partNumber}:${description || ''}:${manufacturer || ''}`
// Check cache
const cached = ebaySearchCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
return cached.results
return { results: cached.results as EbaySearchResult[] }
}
try {
const results = await ebayClient.searchVendingParts(partNumber, description, manufacturer)
ebaySearchCache.set(cacheKey, { results, timestamp: Date.now() })
return results
} catch (error) {
console.error(`Error searching eBay for part ${partNumber}:`, error)
// Return empty array if API fails
return []
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 searchViaApi = async (categoryId?: string): Promise<EbaySearchResponse> => {
const params = new URLSearchParams({
keywords: buildQuery(),
maxResults: '3',
sortOrder: 'BestMatch',
})
if (categoryId) {
params.set('categoryId', categoryId)
}
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) {
return {
results: [],
error: error instanceof Error ? error.message : 'Failed to search eBay',
}
}
}
const categorySearch = await searchViaApi('11700')
if (categorySearch.results.length > 0) {
ebaySearchCache.set(cacheKey, {
results: categorySearch.results,
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,
}
}
/**
* Enhance parts data with real-time eBay listings
*/
async function enhancePartsData(parts: PartForPage[]): Promise<PartForPage[]> {
if (!ebayClient.isConfigured()) {
return parts
}
async function enhancePartsData(parts: PartForPage[]): Promise<{
parts: PartForPage[]
error?: string
}> {
let firstError: string | undefined
const enhancedParts = await Promise.all(parts.map(async (part) => {
// Only search for parts without existing eBay listings
if (part.ebayListings.length === 0) {
const ebayResults = await searchEBayForParts(part.partNumber, part.description)
part.ebayListings = ebayResults.map(result => ({
itemId: result.itemId,
title: result.title,
price: result.price,
currency: result.currency,
imageUrl: result.imageUrl,
viewItemUrl: result.viewItemUrl,
condition: result.condition,
shippingCost: result.shippingCost,
affiliateLink: result.affiliateLink,
}))
if (ebayResults.error && !firstError) {
firstError = ebayResults.error
}
return {
...part,
ebayListings: ebayResults.results.map((result) => ({
itemId: result.itemId,
title: result.title,
price: result.price,
currency: result.currency,
imageUrl: result.imageUrl,
viewItemUrl: result.viewItemUrl,
condition: result.condition,
shippingCost: result.shippingCost,
affiliateLink: result.affiliateLink,
})),
}
}
return part
}))
return enhancedParts
return {
parts: enhancedParts,
error: firstError,
}
}
function findManualParts(
manualFilename: string,
manualParts: ManualPartsLookup,
): PartForPage[] {
if (manualParts[manualFilename]) {
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 []
}
function findManualPagesParts(
manualFilename: string,
pageNumber: number,
manualPagesParts: ManualPagesParts,
): PartForPage[] {
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 []
}
async function getPartsForManualWithStatus(
manualFilename: string,
): Promise<{
parts: PartForPage[]
error?: string
}> {
const { manualParts } = await loadPartsData()
const parts = findManualParts(manualFilename, manualParts)
if (parts.length === 0) {
return { parts: [] }
}
return enhancePartsData(parts)
}
/**
@ -141,34 +329,8 @@ async function enhancePartsData(parts: PartForPage[]): Promise<PartForPage[]> {
export async function getPartsForManual(
manualFilename: string
): Promise<PartForPage[]> {
const { manualParts } = await loadPartsData()
// Try exact match first
if (manualParts[manualFilename]) {
const parts = manualParts[manualFilename]
return enhancePartsData(parts)
}
// Try case-insensitive match
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, parts] of Object.entries(manualParts)) {
if (filename.toLowerCase() === lowerFilename) {
return enhancePartsData(parts)
}
}
// Try partial match (filename without extension)
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 enhancePartsData(parts)
}
}
return []
const result = await getPartsForManualWithStatus(manualFilename)
return result.parts
}
/**
@ -179,39 +341,14 @@ export async function getPartsForPage(
pageNumber: number
): Promise<PartForPage[]> {
const { manualPagesParts } = await loadPartsData()
// Try exact match first
if (manualPagesParts[manualFilename] && manualPagesParts[manualFilename][pageNumber.toString()]) {
const parts = manualPagesParts[manualFilename][pageNumber.toString()]
return enhancePartsData(parts)
const parts = findManualPagesParts(manualFilename, pageNumber, manualPagesParts)
if (parts.length === 0) {
return []
}
// Try case-insensitive match
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, pages] of Object.entries(manualPagesParts)) {
if (filename.toLowerCase() === lowerFilename) {
if (pages[pageNumber.toString()]) {
const parts = pages[pageNumber.toString()]
return enhancePartsData(parts)
}
}
}
// Try partial match
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()]) {
const parts = pages[pageNumber.toString()]
return enhancePartsData(parts)
}
}
}
return []
const enhanced = await enhancePartsData(parts)
return enhanced.parts
}
/**
@ -220,13 +357,19 @@ export async function getPartsForPage(
export async function getTopPartsForManual(
manualFilename: string,
limit: number = 5
): Promise<PartForPage[]> {
const parts = await getPartsForManual(manualFilename)
): Promise<{
parts: PartForPage[]
error?: string
}> {
const { parts, error } = await getPartsForManualWithStatus(manualFilename)
// Sort by number of eBay listings (more listings = more relevant)
const sorted = parts.sort((a, b) => b.ebayListings.length - a.ebayListings.length)
return sorted.slice(0, limit)
return {
parts: sorted.slice(0, limit),
error,
}
}
/**
@ -234,7 +377,7 @@ export async function getTopPartsForManual(
*/
export async function hasPartsForManual(manualFilename: string): Promise<boolean> {
const parts = await getPartsForManual(manualFilename)
return parts.length > 0 && parts.some(part => part.ebayListings.length > 0)
return parts.length > 0
}
/**
@ -244,9 +387,9 @@ export async function hasPartsForManual(manualFilename: string): Promise<boolean
export async function getManualsWithParts(): Promise<Set<string>> {
const { manualParts } = await loadPartsData()
const manualsWithParts = new Set<string>()
for (const [filename, parts] of Object.entries(manualParts)) {
if (parts.length > 0 && parts.some(part => part.ebayListings.length > 0)) {
if (parts.length > 0) {
manualsWithParts.add(filename)
// Also add variations for matching
manualsWithParts.add(filename.toLowerCase())
@ -254,7 +397,7 @@ export async function getManualsWithParts(): Promise<Set<string>> {
manualsWithParts.add(filename.replace(/\.pdf$/i, '').toLowerCase())
}
}
return manualsWithParts
}