diff --git a/README.md b/README.md index 48cefddb3..c683240c9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,48 @@ curl -sS --get "$BUYWHERE_BASE_URL/v1/deals" \ --data-urlencode "limit=10" ``` +## TypeScript SDK + +Install the official npm package: + +```bash +npm install @buywhere/sdk +``` + +Basic search: + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +const results = await client.search({ + q: "wireless headphones", + limit: 5, + in_stock: true, +}); + +for (const product of results.items) { + console.log(`${product.name} | ${product.currency} ${product.price} | ${product.source}`); +} +``` + +Price comparison for a known product: + +```typescript +const search = await client.search({ q: "Nintendo Switch OLED", limit: 1 }); +const product = search.items[0]; + +if (product) { + const comparison = await client.compare({ product_id: product.id }); + console.log(comparison.highlights?.cheapest); +} +``` + +Full package docs and more examples: [sdk/npm/README.md](sdk/npm/README.md) + +Runnable scripts live in [sdk/npm/examples](sdk/npm/examples). + ## MCP Integration BuyWhere is listed in the awesome-mcp-servers registry. Connect to Claude Desktop, Cursor, Windsurf, or any MCP-compatible AI client in seconds. diff --git a/alembic/versions/20260425190000_merge_billing_and_webhook_heads.py b/alembic/versions/20260425190000_merge_billing_and_webhook_heads.py new file mode 100644 index 000000000..d1b721845 --- /dev/null +++ b/alembic/versions/20260425190000_merge_billing_and_webhook_heads.py @@ -0,0 +1,25 @@ +"""merge billing and webhook heads before affiliate click tracking + +Revision ID: 20260425190000 +Revises: 20260425153000, 20260425173000 +Create Date: 2026-04-25 19:00:00.000000 +""" + +from typing import Sequence, Union + + +revision: str = "20260425190000" +down_revision: Union[str, Sequence[str], None] = ( + "20260425153000", + "20260425173000", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/20260425200000_add_affiliate_click_tracking_tables.py b/alembic/versions/20260425200000_add_affiliate_click_tracking_tables.py new file mode 100644 index 000000000..22c4d2226 --- /dev/null +++ b/alembic/versions/20260425200000_add_affiliate_click_tracking_tables.py @@ -0,0 +1,86 @@ +"""Add affiliate click tracking tables for session-based revenue attribution + +Revision ID: 20260425200000 +Revises: 20260425190000 +Create Date: 2026-04-25 20:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "20260425200000" +down_revision = "20260425190000" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "affiliate_clicks", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("product_id", sa.BigInteger(), nullable=False), + sa.Column("merchant", sa.String(), nullable=False), + sa.Column("platform", sa.String(), nullable=True), + sa.Column("tracking_id", sa.String(), nullable=True), + sa.Column("api_key_id", sa.String(), nullable=True), + sa.Column("agent_id", sa.String(), nullable=True), + sa.Column("affiliate_partner", sa.String(), nullable=True), + sa.Column("destination_url", sa.Text(), nullable=False), + sa.Column("referrer", sa.Text(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.Column("user_ip", sa.Text(), nullable=True), + sa.Column("country", sa.String(2), nullable=True), + sa.Column( + "clicked_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_affiliate_clicks_session_product", "affiliate_clicks", ["session_id", "product_id"]) + op.create_index("idx_affiliate_clicks_merchant", "affiliate_clicks", ["merchant"]) + op.create_index("idx_affiliate_clicks_clicked_at", "affiliate_clicks", ["clicked_at"]) + op.create_index("idx_affiliate_clicks_api_key_id", "affiliate_clicks", ["api_key_id"]) + op.create_index("idx_affiliate_clicks_tracking_id", "affiliate_clicks", ["tracking_id"]) + op.create_index("idx_affiliate_clicks_session_id", "affiliate_clicks", ["session_id"]) + op.create_index("idx_affiliate_clicks_product_id", "affiliate_clicks", ["product_id"]) + + op.create_table( + "affiliate_conversions", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("click_id", sa.BigInteger(), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("product_id", sa.BigInteger(), nullable=False), + sa.Column("merchant", sa.String(), nullable=False), + sa.Column("platform", sa.String(), nullable=True), + sa.Column("tracking_id", sa.String(), nullable=True), + sa.Column("api_key_id", sa.String(), nullable=True), + sa.Column("agent_id", sa.String(), nullable=True), + sa.Column("affiliate_partner", sa.String(), nullable=True), + sa.Column("conversion_revenue", sa.Numeric(12, 4), nullable=True), + sa.Column("currency", sa.String(3), nullable=False, server_default="SGD"), + sa.Column("conversion_type", sa.String(32), nullable=True), + sa.Column("conversion_data", JSONB(), nullable=True), + sa.Column( + "converted_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_affiliate_conversions_click_id", "affiliate_conversions", ["click_id"]) + op.create_index("idx_affiliate_conversions_session_id", "affiliate_conversions", ["session_id"]) + op.create_index("idx_affiliate_conversions_product_id", "affiliate_conversions", ["product_id"]) + op.create_index("idx_affiliate_conversions_merchant", "affiliate_conversions", ["merchant"]) + op.create_index("idx_affiliate_conversions_conversion_type", "affiliate_conversions", ["conversion_type"]) + op.create_index("idx_affiliate_conversions_converted_at", "affiliate_conversions", ["converted_at"]) + op.create_index("idx_affiliate_conversions_api_key_id", "affiliate_conversions", ["api_key_id"]) + + +def downgrade() -> None: + op.drop_table("affiliate_conversions") + op.drop_table("affiliate_clicks") diff --git a/app/auth.py b/app/auth.py index a2ea0bf2c..05ea14ea4 100644 --- a/app/auth.py +++ b/app/auth.py @@ -201,10 +201,63 @@ async def get_current_api_key( return api_key +async def get_optional_api_key( + request: Request, + db: AsyncSession = Depends(get_db), +) -> ApiKey | None: + auth_header = request.headers.get("Authorization") + token = None + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:] + + if not token: + return None + + paperclip_key = await resolve_paperclip_agent_key(token, db) + if paperclip_key is not None: + return paperclip_key + + payload = decode_access_token(token) + if payload and "key_id" in payload: + key_id = payload["key_id"] + result = await db.execute( + select(ApiKey).where(ApiKey.id == key_id, ApiKey.is_active == True) + ) + return result.scalar_one_or_none() + + key_hash = hash_key(token) + result = await db.execute( + select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) + ) + api_key = result.scalar_one_or_none() + + if api_key is None: + result = await db.execute( + select(ApiKey).where( + ApiKey.is_active == True, + ApiKey.key_hash.like("$2%"), + ) + ) + candidates = result.scalars().all() + for candidate in candidates: + if _verify_key_bcrypt(token, candidate.key_hash): + api_key = candidate + break + + if api_key is not None: + await db.execute( + update(ApiKey) + .where(ApiKey.id == api_key.id) + .values(last_used_at=datetime.now(timezone.utc)) + ) + + return api_key + + async def provision_api_key( developer_id: str, name: str, - tier: str = "basic", + tier: str = "free", db: AsyncSession = None, rate_limit: int = None, allowed_origins: list = None, diff --git a/app/main.py b/app/main.py index 77ada6f9b..71e9dfea5 100644 --- a/app/main.py +++ b/app/main.py @@ -12,11 +12,12 @@ from slowapi.middleware import SlowAPIMiddleware from starlette.exceptions import HTTPException as StarletteHTTPException +from app.auth import ApiKeyContextMiddleware from app.config import get_settings from app.rate_limit import limiter, TierRateLimitMiddleware, RedisPerMinuteRateLimitMiddleware -from app.request_logging import RequestLoggingMiddleware +from app.request_logging import RequestLoggingMiddleware, get_request_id, log_api_error from app.usage_metering import UsageMeteringMiddleware -from app.routers import products, categories, keys, deals, ingestion, ingest, search, status, catalog, agents, analytics, admin, developers, webhooks, metrics, alerts, images, changelog, feed, merchants, trending, export, enrichment, health, brands, watchlist, dedup, compare, billing, countries, sitemap, v2, merchant_analytics, affiliate, preferences, import_csv, saved_searches, usage, referrals, coupons, linkless_attribution, scraper_assignments, scraper_alerts, scraper_refresh, agent_native, newsletter, user_watchlist, user_alerts, users, referral_landing, push_notifications, user_notification_preferences, price_drops, growth, feature_flags, signup, stats, public_alerts, alertmanager_webhooks, auth_compat +from app.routers import products, categories, keys, deals, ingestion, ingest, search, status, catalog, agents, analytics, admin, developers, webhooks, metrics, alerts, images, changelog, feed, merchants, trending, export, enrichment, health, brands, watchlist, dedup, compare, billing, countries, sitemap, v2, merchant_analytics, affiliate, affiliate_click, preferences, import_csv, saved_searches, usage, referrals, coupons, linkless_attribution, scraper_assignments, scraper_alerts, scraper_refresh, agent_native, newsletter, user_watchlist, user_alerts, users, referral_landing, push_notifications, user_notification_preferences, price_drops, growth, feature_flags, signup, stats, public_alerts, alertmanager_webhooks, auth_compat, auth, agent_health, webhook_catalog_update from app import clickthrough from app.graphql import graphql_router from app.versioning import VersionRoutingMiddleware @@ -32,6 +33,21 @@ MAX_QUERY_LENGTH = 500 +try: + from app.routers import prometheus_metrics + from app.routers.prometheus_metrics import PrometheusMiddleware +except ModuleNotFoundError as exc: + if exc.name != "prometheus_client": + raise + prometheus_metrics = None + + class PrometheusMiddleware: # type: ignore[no-redef] + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + await self.app(scope, receive, send) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -81,8 +97,10 @@ async def lifespan(app: FastAPI): app.add_middleware(SlowAPIMiddleware) app.add_middleware(RedisPerMinuteRateLimitMiddleware) app.add_middleware(TierRateLimitMiddleware) +app.add_middleware(ApiKeyContextMiddleware) app.add_middleware(RequestLoggingMiddleware) app.add_middleware(UsageMeteringMiddleware) +app.add_middleware(PrometheusMiddleware) if is_sentry_enabled(): app.add_middleware(SentrySlowQueryMiddleware) @@ -110,10 +128,13 @@ async def lifespan(app: FastAPI): app.include_router(agents.router) app.include_router(developers.router, prefix="/v1") app.include_router(auth_compat.router, prefix="/v1") +app.include_router(auth_compat.api_compat_router, prefix="/api") +app.include_router(auth.router, prefix="/v1") app.include_router(analytics.router, prefix="/v1") app.include_router(admin.router, prefix="/v1") app.include_router(feature_flags.router) app.include_router(webhooks.router, prefix="/v1") +app.include_router(webhook_catalog_update.router) app.include_router(alertmanager_webhooks.router) app.include_router(metrics.router, prefix="/v1") app.include_router(alerts.router, prefix="/v1") @@ -132,6 +153,7 @@ async def lifespan(app: FastAPI): app.include_router(dedup.dedup_ingest_router, prefix="/v1") app.include_router(compare.router, prefix="/v1") app.include_router(affiliate.router, prefix="/v1") +app.include_router(affiliate_click.router, prefix="/v1") app.include_router(billing.router, prefix="/v1") app.include_router(usage.router, prefix="/v1") app.include_router(agent_native.router) @@ -155,6 +177,9 @@ async def lifespan(app: FastAPI): app.include_router(growth.router) app.include_router(signup.router) app.include_router(stats.router) +app.include_router(agent_health.router) +if prometheus_metrics is not None: + app.include_router(prometheus_metrics.router) # /health alias — monitors and Docker HEALTHCHECK use this; actual logic is at /v1/health @app.get("/health", include_in_schema=False) @@ -176,14 +201,14 @@ def error_response(code: str, message: str, details: Union[dict, list, None] = N @app.middleware("http") async def add_request_id(request: Request, call_next): - request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4()) + request_id = get_request_id(request) response = await call_next(request) response.headers["X-Request-Id"] = request_id return response # AI crawler / Perplexity-friendly headers on public endpoints -AI_INDEXABLE_PREFIXES = ("/products", "/categories", "/search", "/deals", "/v2/products", "/v2/search", "/api/docs", "/api/redoc", "/llms.txt") +AI_INDEXABLE_PREFIXES = ("/products", "/categories", "/search", "/deals", "/compare", "/v2/products", "/v2/search", "/api/docs", "/api/redoc", "/llms.txt") @app.middleware("http") async def add_ai_crawler_headers(request: Request, call_next): @@ -197,8 +222,16 @@ async def add_ai_crawler_headers(request: Request, call_next): @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request: Request, exc: StarletteHTTPException): + if exc.status_code >= 500: + log_api_error(request, exc, exc.status_code, message="HTTP exception response") if exc.status_code == 404: return error_response("NOT_FOUND", "The requested resource was not found", status_code=404) + if exc.status_code >= 500: + return JSONResponse( + status_code=exc.status_code, + content={"error": {"code": f"HTTP_{exc.status_code}", "message": exc.detail if hasattr(exc, "detail") else "An error occurred", "details": {}}}, + headers={"Cache-Control": "no-store"}, + ) return error_response( f"HTTP_{exc.status_code}", exc.detail if hasattr(exc, "detail") else "An error occurred", @@ -208,6 +241,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException): @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): + log_api_error(request, exc, 422, message="Request validation failed") errors = [] for error in exc.errors(): field = ".".join(str(loc) for loc in error["loc"] if loc not in ("body", "query", "path")) @@ -271,6 +305,7 @@ def _get_country_from_request(request: Request) -> str: @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): + log_api_error(request, exc, 500, message="Unhandled exception response") logger.exception(f"Unhandled exception: {exc}") if is_sentry_enabled(): @@ -290,7 +325,11 @@ async def global_exception_handler(request: Request, exc: Exception): is_p0=is_p0, ) - return error_response("INTERNAL_ERROR", "An internal server error occurred", status_code=500) + return JSONResponse( + status_code=500, + content={"error": {"code": "INTERNAL_ERROR", "message": "An internal server error occurred", "details": {}}}, + headers={"Cache-Control": "no-store"}, + ) @app.exception_handler(RateLimitExceeded) @@ -331,6 +370,40 @@ async def chatgpt_openapi(): ) +@app.get("/.well-known/ai-plugin.json", tags=["integrations"], summary="AI agent plugin manifest for ChatGPT/GPT Builder discovery") +async def ai_plugin_manifest(): + api_base = getattr(settings, "app_base_url", "https://api.buywhere.ai") + manifest = { + "schema_version": "v1", + "name_for_model": "buywhere_product_catalog", + "name_for_human": "BuyWhere Product Catalog", + "description_for_model": ( + "Search and compare millions of products across major e-commerce platforms including " + "Shopee, Lazada, Amazon, Carousell, and 20+ other merchants in Singapore and globally. " + "Find best prices, deals, price history, and product availability. " + "Supports semantic search, category filtering, brand lookup, and multi-platform price comparison. " + "Use for any shopping research, price comparison, product discovery, or deal-finding tasks." + ), + "description_for_human": "Search millions of products across Shopee, Lazada, Amazon, Carousell and 20+ Singapore and global merchants. Find best prices, compare deals, and discover products.", + "auth": { + "type": "none" + }, + "api": { + "type": "openapi", + "url": f"{api_base}/api/openapi.json" + }, + "logo_url": f"{api_base}/static/buywhere-logo.png", + "contact_email": "api@buywhere.ai", + "legal_info_url": f"{api_base}/legal", + "HttpAuthorization": "Bearer", + "override_user_auth_header": "Authorization" + } + return JSONResponse( + content=manifest, + headers={"Cache-Control": "public, max-age=86400"}, + ) + + @app.get("/v1/health", response_model=ComprehensiveHealthReport, tags=["system"], summary="Comprehensive health check with dependency status") async def health_check(request: Request): from app.database import AsyncSessionLocal @@ -375,6 +448,7 @@ async def api_root(): "products": "GET /v1/products", "best_price": "GET /v1/products/best-price", "compare_search": "GET /v1/products/compare?q=", + "compare_by_ids": "GET /v1/products/compare?ids=", "compare_matrix": "POST /v1/products/compare", "compare_diff": "POST /v1/products/compare/diff", "trending": "GET /v1/products/trending", @@ -459,8 +533,25 @@ async def custom_swagger_ui(): @app.get("/api/docs", include_in_schema=False) async def api_swagger_ui(): - from starlette.responses import RedirectResponse - return RedirectResponse(url="/docs") + from starlette.responses import FileResponse + return FileResponse("templates/swagger.html") + + +@app.get("/docs/api", include_in_schema=False) +async def docs_api_swagger_ui(): + from starlette.responses import FileResponse + return FileResponse("templates/swagger.html") + + +@app.get("/openapi.json", tags=["integrations"], summary="Full OpenAPI 3.0 specification", include_in_schema=False) +async def openapi_spec(): + import json + from pathlib import Path + spec_path = Path(__file__).resolve().parent.parent / "openapi.yaml" + return JSONResponse( + content=json.loads(spec_path.read_text()), + headers={"Cache-Control": "public, max-age=3600"}, + ) @app.get("/quickstart", include_in_schema=False) diff --git a/app/models/affiliate.py b/app/models/affiliate.py new file mode 100644 index 000000000..2b7ac9ec2 --- /dev/null +++ b/app/models/affiliate.py @@ -0,0 +1,61 @@ +from sqlalchemy import Column, String, DateTime, BigInteger, Index, Text, Numeric, Boolean, func +from sqlalchemy.dialects.postgresql import JSONB +from app.database import Base + + +class AffiliateClick(Base): + __tablename__ = "affiliate_clicks" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + session_id = Column(String, nullable=False, index=True) + product_id = Column(BigInteger, nullable=False, index=True) + merchant = Column(String, nullable=False, index=True) + platform = Column(String, nullable=True) + tracking_id = Column(String, nullable=True, index=True) + api_key_id = Column(String, nullable=True, index=True) + agent_id = Column(String, nullable=True, index=True) + affiliate_partner = Column(String, nullable=True, index=True) + destination_url = Column(Text, nullable=False) + referrer = Column(Text, nullable=True) + user_agent = Column(Text, nullable=True) + user_ip = Column(Text, nullable=True) + country = Column(String(2), nullable=True) + clicked_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + __table_args__ = ( + Index("idx_affiliate_clicks_session_product", "session_id", "product_id"), + Index("idx_affiliate_clicks_merchant", "merchant"), + Index("idx_affiliate_clicks_clicked_at", "clicked_at"), + Index("idx_affiliate_clicks_api_key_id", "api_key_id"), + Index("idx_affiliate_clicks_tracking_id", "tracking_id"), + ) + + +class AffiliateConversion(Base): + __tablename__ = "affiliate_conversions" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + click_id = Column(BigInteger, nullable=False, index=True) + session_id = Column(String, nullable=False, index=True) + product_id = Column(BigInteger, nullable=False, index=True) + merchant = Column(String, nullable=False, index=True) + platform = Column(String, nullable=True) + tracking_id = Column(String, nullable=True, index=True) + api_key_id = Column(String, nullable=True, index=True) + agent_id = Column(String, nullable=True, index=True) + affiliate_partner = Column(String, nullable=True, index=True) + conversion_revenue = Column(Numeric(12, 4), nullable=True) + currency = Column(String(3), nullable=False, default="SGD") + conversion_type = Column(String(32), nullable=True) + conversion_data = Column(JSONB, nullable=True) + converted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + __table_args__ = ( + Index("idx_affiliate_conversions_click_id", "click_id"), + Index("idx_affiliate_conversions_session_id", "session_id"), + Index("idx_affiliate_conversions_product_id", "product_id"), + Index("idx_affiliate_conversions_merchant", "merchant"), + Index("idx_affiliate_conversions_conversion_type", "conversion_type"), + Index("idx_affiliate_conversions_converted_at", "converted_at"), + Index("idx_affiliate_conversions_api_key_id", "api_key_id"), + ) \ No newline at end of file diff --git a/app/routers/affiliate_click.py b/app/routers/affiliate_click.py new file mode 100644 index 000000000..9902634ad --- /dev/null +++ b/app/routers/affiliate_click.py @@ -0,0 +1,303 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import bearer_scheme, decode_access_token +from app.affiliate_links import get_underlying_affiliate_url, parse_tracking_id, is_valid_url +from app.database import get_db +from app.models.product import ApiKey, Product +from app.models.affiliate import AffiliateClick, AffiliateConversion +from app.rate_limit import limiter +from app.logging_centralized import get_logger + +logger = get_logger("affiliate-click-service") + +router = APIRouter(prefix="/affiliate", tags=["affiliate"]) + + +def get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def anonymize_ip(ip: str) -> str: + if not ip or ip == "unknown": + return ip + try: + parts = ip.split(".") + if len(parts) == 4: + return ".".join(parts[:3]) + ".0" + except: + pass + return ip + + +async def get_optional_api_key( + request: Request, + db: AsyncSession = Depends(get_db), +) -> ApiKey | None: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:] + payload = decode_access_token(token) + if payload and "key_id" in payload: + key_id = payload["key_id"] + result = await db.execute( + select(ApiKey).where(ApiKey.id == key_id, ApiKey.is_active == True) + ) + return result.scalar_one_or_none() + return None + + +class AffiliateClickRequest(BaseModel): + product_id: int + session_id: str + platform: Optional[str] = None + merchant: Optional[str] = None + tracking_id: Optional[str] = None + agent_id: Optional[str] = None + affiliate_partner: Optional[str] = None + destination_url: Optional[str] = None + referrer: Optional[str] = None + + +class AffiliateClickResponse(BaseModel): + click_id: int + redirect_url: str + logged: bool + + +class AffiliateStatsRequest(BaseModel): + session_id: Optional[str] = None + merchant: Optional[str] = None + platform: Optional[str] = None + days: int = Query(30, ge=1, le=365) + + +class AffiliateClickStats(BaseModel): + total_clicks: int + unique_sessions: int + by_platform: dict + + +class AffiliateConversionStats(BaseModel): + total_conversions: int + total_revenue: float + currency: str + by_platform: dict + + +class AffiliateStatsResponse(BaseModel): + clicks: AffiliateClickStats + conversions: AffiliateConversionStats + + +@router.post("/click", summary="Log affiliate click and redirect to merchant") +@limiter.limit("100/minute") +async def log_affiliate_click( + request: Request, + body: AffiliateClickRequest, + response: Response, + db: AsyncSession = Depends(get_db), + api_key: ApiKey | None = Depends(get_optional_api_key), +): + """Log an affiliate click and redirect to the merchant URL. + + This endpoint: + 1. Validates the product exists + 2. Logs the click to affiliate_clicks table + 3. Returns a 302 redirect to the merchant's affiliate URL + """ + product_result = await db.execute( + select(Product).where(Product.id == body.product_id, Product.is_active == True) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + + platform = body.platform or product.source + merchant = body.merchant or product.merchant_id or product.source + + if body.tracking_id: + parsed = parse_tracking_id(body.tracking_id) + if parsed: + product_id_from_tracking, _ = parsed + if product_id_from_tracking == body.product_id: + destination_url = get_underlying_affiliate_url(platform, product.url) + else: + destination_url = body.destination_url or product.url + else: + destination_url = body.destination_url or product.url + else: + destination_url = get_underlying_affiliate_url(platform, product.url) + + if not destination_url or not is_valid_url(destination_url): + destination_url = product.url + + client_ip = anonymize_ip(get_client_ip(request)) + + click_record = AffiliateClick( + session_id=body.session_id, + product_id=body.product_id, + merchant=merchant, + platform=platform, + tracking_id=body.tracking_id, + api_key_id=str(api_key.id) if api_key else None, + agent_id=body.agent_id, + affiliate_partner=body.affiliate_partner, + destination_url=destination_url, + referrer=body.referrer or request.headers.get("Referer"), + user_agent=request.headers.get("User-Agent"), + user_ip=client_ip, + country=request.headers.get("CF-IPCountry") or request.headers.get("X-Country") or None, + ) + db.add(click_record) + await db.commit() + await db.refresh(click_record) + + return RedirectResponse(url=destination_url, status_code=status.HTTP_302_FOUND) + + +@router.get("/stats", response_model=AffiliateStatsResponse, summary="Get affiliate revenue dashboard stats") +@limiter.limit("30/minute") +async def get_affiliate_stats( + request: Request, + session_id: str | None = Query(None, description="Filter by session ID"), + merchant: str | None = Query(None, description="Filter by merchant"), + platform: str | None = Query(None, description="Filter by platform"), + days: int = Query(30, ge=1, le=365, description="Number of days to look back"), + db: AsyncSession = Depends(get_db), + api_key: ApiKey = Depends(get_optional_api_key), +): + """Get affiliate click and conversion statistics for the revenue dashboard. + + Returns click counts, unique sessions, and conversion metrics broken down by platform. + """ + cutoff = datetime.now(timezone.utc).replace(microsecond=0) - timedelta(days=days) + + clicks_query = select( + func.count(AffiliateClick.id).label("total_clicks"), + func.count(func.distinct(AffiliateClick.session_id)).label("unique_sessions"), + AffiliateClick.platform, + ).where( + AffiliateClick.clicked_at >= cutoff + ) + + if session_id: + clicks_query = clicks_query.where(AffiliateClick.session_id == session_id) + if merchant: + clicks_query = clicks_query.where(AffiliateClick.merchant == merchant) + if platform: + clicks_query = clicks_query.where(AffiliateClick.platform == platform) + + clicks_query = clicks_query.group_by(AffiliateClick.platform) + clicks_result = await db.execute(clicks_query) + clicks_rows = clicks_result.all() + + conversions_query = select( + func.count(AffiliateConversion.id).label("total_conversions"), + func.coalesce(func.sum(AffiliateConversion.conversion_revenue), 0).label("total_revenue"), + AffiliateConversion.platform, + ).where( + AffiliateConversion.converted_at >= cutoff + ) + + if session_id: + conversions_query = conversions_query.where(AffiliateConversion.session_id == session_id) + if merchant: + conversions_query = conversions_query.where(AffiliateConversion.merchant == merchant) + if platform: + conversions_query = conversions_query.where(AffiliateConversion.platform == platform) + + conversions_query = conversions_query.group_by(AffiliateConversion.platform) + conversions_result = await db.execute(conversions_query) + conversions_rows = conversions_result.all() + + by_platform_clicks = {} + total_clicks = 0 + total_sessions = 0 + for row in clicks_rows: + by_platform_clicks[row.platform or "unknown"] = { + "clicks": row.total_clicks, + "sessions": row.unique_sessions, + } + total_clicks += row.total_clicks + total_sessions += row.unique_sessions + + by_platform_conversions = {} + total_conversions = 0 + total_revenue = 0.0 + for row in conversions_rows: + by_platform_conversions[row.platform or "unknown"] = { + "conversions": row.total_conversions, + "revenue": float(row.total_revenue or 0), + } + total_conversions += row.total_conversions + total_revenue += float(row.total_revenue or 0) + + return AffiliateStatsResponse( + clicks=AffiliateClickStats( + total_clicks=total_clicks, + unique_sessions=total_sessions, + by_platform=by_platform_clicks, + ), + conversions=AffiliateConversionStats( + total_conversions=total_conversions, + total_revenue=total_revenue, + currency="SGD", + by_platform=by_platform_conversions, + ), + ) + + +@router.post("/conversion", summary="Log an affiliate conversion event") +@limiter.limit("50/minute") +async def log_affiliate_conversion( + request: Request, + click_id: int, + session_id: str, + product_id: int, + conversion_revenue: float | None = None, + conversion_type: str | None = None, + conversion_data: dict | None = None, + db: AsyncSession = Depends(get_db), + api_key: ApiKey | None = Depends(get_optional_api_key), +): + """Log a conversion event for an affiliate click. + + This should be called when a user completes a purchase or other conversion action + after clicking through an affiliate link. + """ + click_result = await db.execute( + select(AffiliateClick).where(AffiliateClick.id == click_id) + ) + click = click_result.scalar_one_or_none() + if not click: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Click not found") + + conversion_record = AffiliateConversion( + click_id=click_id, + session_id=session_id, + product_id=product_id, + merchant=click.merchant, + platform=click.platform, + tracking_id=click.tracking_id, + api_key_id=str(api_key.id) if api_key else click.api_key_id, + agent_id=click.agent_id, + affiliate_partner=click.affiliate_partner, + conversion_revenue=conversion_revenue, + currency="SGD", + conversion_type=conversion_type, + conversion_data=conversion_data, + ) + db.add(conversion_record) + await db.commit() + await db.refresh(conversion_record) + + return {"conversion_id": conversion_record.id, "logged": True} diff --git a/app/routers/products.py b/app/routers/products.py index cdc7a4bf6..dfb040972 100644 --- a/app/routers/products.py +++ b/app/routers/products.py @@ -2,21 +2,22 @@ import io import json import logging +import re from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import List, Optional, Dict import httpx -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi.responses import StreamingResponse, RedirectResponse, JSONResponse -from sqlalchemy import case, func, select, text +from sqlalchemy import and_, case, func, or_, select, text from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.affiliate_links import get_affiliate_url, get_underlying_affiliate_url, is_valid_url from app.auth import get_current_api_key from app.database import get_db -from app.models.product import ApiKey, Click, Product, PriceHistory, ProductView, ProductMatch, ProductReview, ProductQuestion, ProductAnswer +from app.models.product import ApiKey, Click, Product, PriceHistory, ProductMatch, ProductReview, ProductQuestion, ProductAnswer from app.rate_limit import limiter, rate_limit_from_request AVAILABILITY_CACHE_TTL = 3600 @@ -68,7 +69,7 @@ def _is_stale(last_checked: Optional[datetime]) -> bool: from app.schemas.product import ( ProductListResponse, ProductResponse, CompareMatrixRequest, CompareMatrixResponse, CompareMatrixEntry, - TrendingResponse, TrendingMatch, + TrendingResponse, CompareDiffRequest, CompareDiffResponse, CompareDiffEntry, FieldDiff, PriceHistoryResponse, PriceHistoryEntry, PriceStats, PricePredictionResponse, RecommendationMatch, RecommendationsResponse, @@ -91,17 +92,161 @@ def _is_stale(last_checked: Optional[datetime]) -> bool: QuestionCreateRequest, AnswerCreateRequest, QuestionResponse, AnswerResponse, QuestionDetailResponse, QuestionListResponse, V1ProductSearchResponse, V1ProductSearchItem, V1ProductSearchMeta, + ProductAutocompleteResponse, ) from app.schemas.product import CompareResponse from app.schemas.product import CompareMatch from app.schemas.product import CompareHighlights from app import cache from app.currency import convert_price, build_currency_headers, SUPPORTED_CURRENCIES, get_exchange_rate +from app.middleware.cache_headers import build_last_modified, build_product_etag, etag_matches from app.routers.deals import DealItem, DealsResponse as DealsResponseBase +from app.services.trending import get_trending_scores, record_trending_product_event +from app.services.typesense_search import typesense_autocomplete logger = logging.getLogger("buywhere_api") router = APIRouter(prefix="/products", tags=["products"]) +LOW_CONFIDENCE_MATCH_THRESHOLD = 0.90 +BATCH_PRODUCT_FIELD_ALIASES = {"url": "buy_url"} +BATCH_PRODUCT_DEFAULT_FIELDS = ["id", "name", "price", "currency", "url"] + +COUNTRY_NAMES = { + "SG": "Singapore", + "MY": "Malaysia", + "PH": "Philippines", + "TH": "Thailand", + "VN": "Vietnam", + "US": "United States", +} + +REGION_NAMES = { + "SG": "Singapore", + "US": "United States", + "VN": "Vietnam", + "TH": "Thailand", + "MY": "Malaysia", +} + +SOURCE_COUNTRY_HINTS = { + "SG": ("shopee_sg", "lazada_sg", "carousell_sg", "qoo10_sg", "amazon.sg", "carousell.sg", "decathlon.sg"), + "MY": ("shopee_my", "lazada_my", "carousell_my"), + "PH": ("shopee_ph", "lazada_ph"), + "TH": ("shopee_th", "lazada_th"), + "VN": ("shopee_vn", "lazada_vn"), + "US": ( + "amazon_us", + "walmart_us", + "target_us", + "bestbuy_us", + "chewy_us", + "costco_us", + "homedepot_us", + "nordstrom_us", + "macys_us", + "sephora_us", + "lowes_us", + "kroger_us", + "cvs_us", + "walgreens_us", + "kohls_us", + "adidas_us", + "etsy_us", + "bhphoto_us", + "zappos_us", + "wayfair_us", + "amazon.com", + "bestbuy.com", + ), +} + +ACCESSORY_TERMS = ( + "accessory", + "adapter", + "armband", + "battery", + "cable", + "case", + "charger", + "container", + "cover", + "desk", + "filter", + "holder", + "liner", + "mount", + "organizer", + "paper", + "part", + "replacement", + "scoop", + "stand", + "straw", + "strap", + "table", + "tray", +) + + +def _merchant_edge_case_for_product(p: Product) -> Optional[str]: + merchant_id = (p.merchant_id or "").strip() + source = (p.source or "").strip() + + if not merchant_id: + return "empty_merchant_id" + if source and merchant_id == source: + return "duplicate_merchant" + if merchant_id.lower() in {"unknown", "unknown_merchant", "n/a", "na", "none", "null"}: + return "unknown_merchant" + return None + + +def _normalized_codes(value: Optional[str], valid_codes: Dict[str, str], label: str) -> Optional[List[str]]: + if value is None: + return None + codes = [token.strip().upper() for token in value.split(",") if token.strip()] + invalid = [code for code in codes if code not in valid_codes] + if invalid: + raise HTTPException( + status_code=422, + detail=f"Invalid {label} code(s): {', '.join(invalid)}. Supported: {', '.join(valid_codes.keys())}", + ) + return codes + + +def _source_hint_condition(source_column, hint: str): + source_lc = func.lower(source_column) + return or_(source_lc == hint, source_lc.like(f"{hint}_%"), source_lc.like(f"{hint}.%")) + + +def _inferred_country_expr(): + whens = [] + for country_code, hints in SOURCE_COUNTRY_HINTS.items(): + for hint in hints: + whens.append((_source_hint_condition(Product.source, hint.lower()), country_code)) + return case(*whens, else_=func.upper(Product.country_code)) + + +def _apply_market_filters(query, country: Optional[str], region: Optional[str]): + inferred_country = _inferred_country_expr() + + if country is not None: + country_codes = _normalized_codes(country, COUNTRY_NAMES, "country") + query = query.where(inferred_country.in_(country_codes)) + + if region is not None: + region_codes = _normalized_codes(region, REGION_NAMES, "region") + query = query.where(func.lower(inferred_country).in_([code.lower() for code in region_codes])) + + return query + + +def _accessory_penalty_pattern(query: str) -> Optional[str]: + lowered_query = query.lower() + if any(re.search(rf"(^|[^a-z0-9]){re.escape(term)}([^a-z0-9]|$)", lowered_query) for term in ACCESSORY_TERMS): + return None + joined = "|".join(re.escape(term) for term in ACCESSORY_TERMS) + return rf"(^|[^a-z0-9])(?:{joined})([^a-z0-9]|$)" @router.get("/", response_model=ProductListResponse, summary="List all products") @@ -187,7 +332,13 @@ def _compute_price_trend(db: AsyncSession, product_id: int) -> Optional[str]: return "stable" -def _map_product(p: Product, price_trend: Optional[str] = None, target_currency: Optional[str] = None, confidence_score: Optional[float] = None) -> ProductResponse: +def _map_product( + p: Product, + price_trend: Optional[str] = None, + target_currency: Optional[str] = None, + confidence_score: Optional[float] = None, + trend_score: Optional[int] = None, +) -> ProductResponse: converted_price = None converted_currency = None if target_currency and target_currency != p.currency: @@ -226,9 +377,19 @@ def _map_product(p: Product, price_trend: Optional[str] = None, target_currency: updated_at=p.updated_at, price_trend=price_trend, confidence_score=confidence_score, + trend_score=trend_score, ) +def _project_batch_product(product: ProductResponse, fields: List[str]) -> Dict[str, object]: + payload = product.model_dump(mode="json") + projected: Dict[str, object] = {} + for field in fields: + source_field = BATCH_PRODUCT_FIELD_ALIASES.get(field, field) + projected[field] = payload.get(source_field) + return projected + + def _build_compare_match(p: Product, score: float) -> CompareMatch: return CompareMatch( id=p.id, @@ -253,6 +414,9 @@ def _build_compare_match(p: Product, score: float) -> CompareMatch: metadata=p.metadata_, updated_at=p.updated_at, match_score=round(score, 3), + low_confidence=score < LOW_CONFIDENCE_MATCH_THRESHOLD, + confidence_audit_threshold=LOW_CONFIDENCE_MATCH_THRESHOLD, + merchant_edge_case=_merchant_edge_case_for_product(p), ) @@ -311,6 +475,104 @@ def _get_highlights(matches: List[CompareMatch]) -> Optional[CompareHighlights]: ) +async def _build_compare_diff_response( + *, + db: AsyncSession, + product_ids: List[int], + include_image_similarity: Optional[bool] = None, +) -> CompareDiffResponse: + if len(product_ids) < 2 or len(product_ids) > 5: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="product_ids must contain between 2 and 5 IDs", + ) + + result = await db.execute( + select(Product).where( + Product.id.in_(product_ids), + Product.is_active == True, + ) + ) + products = result.scalars().all() + + product_map = {product.id: product for product in products} + missing = [product_id for product_id in product_ids if product_id not in product_map] + if missing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Products not found: {missing}", + ) + + ordered_products = [product_map[product_id] for product_id in product_ids] + sorted_products = sorted(ordered_products, key=lambda product: product.price) + price_ranks = {product.id: index + 1 for index, product in enumerate(sorted_products)} + + entries = [] + for product in ordered_products: + entries.append(CompareDiffEntry( + id=product.id, + sku=product.sku, + source=product.source, + merchant_id=product.merchant_id, + name=product.title, + description=product.description, + price=product.price, + currency=product.currency, + buy_url=product.url, + affiliate_url=get_affiliate_url(product.source, product.url) if product.url else None, + image_url=product.image_url, + brand=product.brand, + category=product.category, + category_path=product.category_path, + rating=product.rating, + is_available=product.is_available, + last_checked=product.last_checked, + metadata=product.metadata_, + updated_at=product.updated_at, + price_rank=price_ranks[product.id], + )) + + compared_fields = [ + ("price", lambda product: product.price), + ("currency", lambda product: product.currency), + ("brand", lambda product: product.brand), + ("category", lambda product: product.category), + ("source", lambda product: product.source), + ("availability", lambda product: product.is_available), + ("image_url", lambda product: product.image_url), + ] + + field_diffs = [] + identical_fields = [] + + for field_name, accessor in compared_fields: + values = [accessor(product) for product in ordered_products] + all_identical = len(set(str(value) for value in values)) <= 1 + if all_identical: + identical_fields.append(field_name) + continue + field_diffs.append(FieldDiff( + field=field_name, + values=values, + all_identical=False, + )) + + cheapest = sorted_products[0] + most_expensive = sorted_products[-1] + spread = most_expensive.price - cheapest.price + spread_pct = float(spread / cheapest.price * 100) if cheapest.price > 0 else 0.0 + + return CompareDiffResponse( + products=entries, + field_diffs=field_diffs, + identical_fields=identical_fields, + cheapest_product_id=cheapest.id, + most_expensive_product_id=most_expensive.id, + price_spread=spread, + price_spread_pct=round(spread_pct, 2), + ) + + @router.get("/search", response_model=ProductListResponse, summary="Search products (v1 API)") @limiter.limit(rate_limit_from_request) async def v1_product_search( @@ -320,15 +582,23 @@ async def v1_product_search( price_min: Optional[Decimal] = Query(None, ge=0, description="Minimum price filter"), price_max: Optional[Decimal] = Query(None, ge=0, description="Maximum price filter"), platform: Optional[str] = Query(None, max_length=100, description="Filter by platform/source"), - sort_by: Optional[str] = Query(None, description="Sort order: relevance, price_asc, price_desc, newest"), + country: Optional[str] = Query(None, description="Filter by country code(s), comma-separated (e.g., SG,US)"), + country_code: Optional[str] = Query(None, description="Alias for country"), + region: Optional[str] = Query(None, description="Filter by region code(s), comma-separated (e.g., SG,US)"), + sort_by: Optional[str] = Query(None, description="Sort order: relevance, price_asc, price_desc, newest, popularity"), limit: int = Query(20, ge=1, le=100, description="Results per page (1-100)"), offset: int = Query(0, ge=0, le=10000, description="Pagination offset (0-10000)"), include_facets: bool = Query(False, description="Include facet counts in response"), currency: Optional[str] = Query(None, description=f"Target currency for price conversion. Supported: {', '.join(SUPPORTED_CURRENCIES)}"), + brand: Optional[str] = Query(None, max_length=200, description="Filter by brand name (case-insensitive)"), + in_stock: Optional[bool] = Query(None, description="Filter to products currently in stock"), + retailer: Optional[str] = Query(None, max_length=100, description="Filter by retailer/source (e.g., lazada, shopee)"), db: AsyncSession = Depends(get_db), api_key: ApiKey = Depends(get_current_api_key), ) -> ProductListResponse: request.state.api_key = api_key + if country_code is not None: + country = country_code if q and len(q) > 500: raise HTTPException( @@ -365,9 +635,14 @@ async def v1_product_search( price_min=str(price_min) if price_min is not None else None, price_max=str(price_max) if price_max is not None else None, platform=platform, + country=country, + region=region, sort_by=sort_by, limit=limit, offset=offset, + brand=brand, + in_stock=str(in_stock) if in_stock is not None else None, + retailer=retailer, ) cached = await cache.cache_get(cache_key) @@ -378,15 +653,36 @@ async def v1_product_search( highlight_query = None if q: + accessory_pattern = _accessory_penalty_pattern(q) base_query = base_query.where( - text("search_vector @@ plainto_tsquery('english', :q)").bindparams(q=q) + text("search_vector @@ websearch_to_tsquery('english', :q)").bindparams(q=q) ).order_by( - text("ts_rank(search_vector, plainto_tsquery('english', :q_rank), 32) DESC").bindparams(q_rank=q), + text( + """ + ( + ts_rank_cd(search_vector, websearch_to_tsquery('english', :q_rank), 32) * 2.0 + + ts_rank_cd(title_search_vector, websearch_to_tsquery('english', :q_rank), 32) * 3.0 + + CASE WHEN lower(title) = lower(:q_exact) THEN 2.0 ELSE 0 END + + CASE WHEN lower(title) LIKE lower(:q_prefix) THEN 1.0 ELSE 0 END + + CASE WHEN brand IS NOT NULL AND lower(brand) = lower(:q_exact) THEN 0.75 ELSE 0 END + + CASE WHEN brand IS NOT NULL AND lower(brand) LIKE lower(:q_prefix) THEN 0.35 ELSE 0 END + + CASE WHEN category IS NOT NULL AND lower(category) = lower(:q_exact) THEN 0.5 ELSE 0 END + + CASE WHEN category IS NOT NULL AND lower(category) LIKE lower(:q_prefix) THEN 0.25 ELSE 0 END + + CASE WHEN :accessory_pattern IS NOT NULL AND lower(coalesce(title, '')) ~ :accessory_pattern THEN -1.5 ELSE 0 END + + CASE WHEN coalesce(price, 0) <= 0 THEN -1.25 ELSE 0 END + ) DESC + """ + ).bindparams( + q_rank=q, + q_exact=q, + q_prefix=f"{q}%", + accessory_pattern=accessory_pattern, + ), Product.updated_at.desc() ) highlight_query = text( "ts_headline('english', coalesce(title, '') || ' ' || coalesce(description, ''), " - "plainto_tsquery('english', :q_hl), 'MaxWords=50, MinWords=20, MaxFragments=2')" + "websearch_to_tsquery('english', :q_hl), 'MaxWords=50, MinWords=20, MaxFragments=2')" ).bindparams(q_hl=q) else: base_query = base_query.order_by(Product.updated_at.desc()) @@ -399,6 +695,26 @@ async def v1_product_search( base_query = base_query.where(Product.price <= price_max) if platform: base_query = base_query.where(Product.source == platform) + if brand: + base_query = base_query.where(func.lower(Product.brand) == brand.lower()) + if in_stock is not None: + base_query = base_query.where(Product.in_stock == in_stock) + if retailer: + base_query = base_query.where(Product.source == retailer) + base_query = _apply_market_filters(base_query, country, region) + + if sort_by == "price_asc": + base_query = base_query.order_by(Product.price.asc()) + elif sort_by == "price_desc": + base_query = base_query.order_by(Product.price.desc()) + elif sort_by == "newest": + base_query = base_query.order_by(Product.updated_at.desc()) + elif sort_by == "popularity": + base_query = base_query.order_by(Product.review_count.desc().nullslast()) + elif q: + pass + else: + base_query = base_query.order_by(Product.updated_at.desc()) if offset == 0: count_query = select(func.count()).select_from(base_query.subquery()) @@ -417,7 +733,7 @@ async def v1_product_search( Product.id, text( "ts_headline('english', coalesce(title, '') || ' ' || coalesce(description, ''), " - "plainto_tsquery('english', :q_hl), 'MaxWords=50, MinWords=20, MaxFragments=2') as headline" + "websearch_to_tsquery('english', :q_hl), 'MaxWords=50, MinWords=20, MaxFragments=2') as headline" ) ).where(Product.id.in_(product_ids)).params(q_hl=q) hl_results = await db.execute(hl_query) @@ -430,8 +746,9 @@ async def v1_product_search( facet_base_query = select(Product).where(Product.is_active) if q: facet_base_query = facet_base_query.where( - text("search_vector @@ plainto_tsquery('english', :fq)").bindparams(fq=q) + text("search_vector @@ websearch_to_tsquery('english', :fq)").bindparams(fq=q) ) + facet_base_query = _apply_market_filters(facet_base_query, country, region) category_facet = select(Product.category, func.count(Product.id)).select_from( facet_base_query.subquery() @@ -581,17 +898,55 @@ async def best_price( return response -@router.get("/compare", response_model=CompareSearchResponse, summary="Search and compare the same product across all sources") +@router.get("/compare", response_model=CompareSearchResponse | CompareDiffResponse, summary="Search across sources or compare products directly by IDs") @limiter.limit(rate_limit_from_request) async def compare_product_search( request: Request, - q: str = Query(..., min_length=2, description="Product search query (e.g. iphone 15)"), + q: Optional[str] = Query(None, min_length=2, description="Product search query (e.g. iphone 15)"), + ids: Optional[str] = Query(None, min_length=1, description="Comma-separated product IDs for side-by-side comparison"), limit: int = Query(10, ge=1, le=50, description="Max seed products from search to find matches for"), db: AsyncSession = Depends(get_db), api_key: ApiKey = Depends(get_current_api_key), -) -> CompareSearchResponse: +) -> CompareSearchResponse | CompareDiffResponse: request.state.api_key = api_key + if q and ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide either q or ids, not both", + ) + + if ids: + raw_ids = [value.strip() for value in ids.split(",") if value.strip()] + try: + numeric_ids = [int(value) for value in raw_ids] + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="ids must be a comma-separated list of numeric integers", + ) from exc + + cache_key = cache.build_cache_key( + "products:compare_get_by_ids", + product_ids=numeric_ids, + ) + cached = await cache.cache_get(cache_key) + if cached: + return CompareDiffResponse(**cached) + + response = await _build_compare_diff_response( + db=db, + product_ids=numeric_ids, + ) + await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) + return response + + if not q: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Either q or ids is required", + ) + cache_key = cache.build_cache_key( "products:compare_search", q=q, @@ -796,108 +1151,87 @@ async def compare_products_diff( cached = await cache.cache_get(cache_key) if cached: return CompareDiffResponse(**cached) + response = await _build_compare_diff_response( + db=db, + product_ids=body.product_ids, + include_image_similarity=body.include_image_similarity, + ) - if len(body.product_ids) < 2 or len(body.product_ids) > 5: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="product_ids must contain between 2 and 5 IDs", - ) + await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) - result = await db.execute( - select(Product).where( - Product.id.in_(body.product_ids), - Product.is_active == True, - ) - ) - products = result.scalars().all() + return response - if len(products) != len(body.product_ids): - found_ids = {p.id for p in products} - missing = [id for id in body.product_ids if id not in found_ids] - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Products not found: {missing}", - ) - sorted_products = sorted(products, key=lambda p: p.price) - price_ranks = {p.id: i + 1 for i, p in enumerate(sorted_products)} +@router.get("/autocomplete", response_model=ProductAutocompleteResponse, summary="Fast product search autocomplete — no auth required") +@limiter.limit("60/minute") +async def product_autocomplete( + request: Request, + q: str = Query(..., min_length=1, max_length=100, description="Search prefix (min 1 char)"), + limit: int = Query(5, ge=1, le=20, description="Number of suggestions (1-20)"), + db: AsyncSession = Depends(get_db), +) -> ProductAutocompleteResponse: + cache_key = cache.build_cache_key( + "v1:products:autocomplete", + q=q.lower(), + limit=limit, + ) + cached = await cache.cache_get(cache_key) + if cached: + return ProductAutocompleteResponse(**cached) - entries = [] - for p in products: - entries.append(CompareDiffEntry( - id=p.id, - sku=p.sku, - source=p.source, - merchant_id=p.merchant_id, - name=p.title, - description=p.description, - price=p.price, - currency=p.currency, - buy_url=p.url, - affiliate_url=get_affiliate_url(p.source, p.url) if p.url else None, - image_url=p.image_url, - brand=p.brand, - category=p.category, - category_path=p.category_path, - rating=p.rating, - is_available=p.is_available, - last_checked=p.last_checked, - metadata=p.metadata_, - updated_at=p.updated_at, - price_rank=price_ranks[p.id], - )) + start_time = time.perf_counter() - compared_fields = [ - ("price", lambda p: p.price), - ("currency", lambda p: p.currency), - ("brand", lambda p: p.brand), - ("category", lambda p: p.category), - ("source", lambda p: p.source), - ("availability", lambda p: p.is_available), - ] + result = await typesense_autocomplete(q, limit=limit) - field_diffs = [] - identical_fields = [] + if result is not None: + suggestions, _ = result + else: + prefix = q.lower().replace(" ", " & ") + try: + fts_query = text("title_search_vector @@ to_tsquery('english', :prefix_q)").bindparams(prefix_q=prefix + ":*") + except Exception: + prefix_simple = q.lower().replace(" ", "") + fts_query = text("title_search_vector @@ plainto_tsquery('english', :prefix_q)").bindparams(prefix_q=prefix_simple) - for field_name, accessor in compared_fields: - values = [accessor(p) for p in products] - all_identical = len(set(str(v) for v in values)) <= 1 - if all_identical: - identical_fields.append(field_name) - else: - field_diffs.append(FieldDiff( - field=field_name, - values=values, - all_identical=False, - )) + title_query = ( + select(Product.title) + .where(Product.is_active == True) + .where(fts_query) + .group_by(Product.title) + .order_by(func.count(Product.id).desc()) + .limit(limit) + ) + title_result = await db.execute(title_query) + suggestions = list(dict.fromkeys(row[0] for row in title_result.fetchall())) - cheapest = sorted_products[0] - most_expensive = sorted_products[-1] - spread = most_expensive.price - cheapest.price - spread_pct = float(spread / cheapest.price * 100) if cheapest.price > 0 else 0.0 + elapsed_ms = (time.perf_counter() - start_time) * 1000 - response = CompareDiffResponse( - products=entries, - field_diffs=field_diffs, - identical_fields=identical_fields, - cheapest_product_id=cheapest.id, - most_expensive_product_id=most_expensive.id, - price_spread=spread, - price_spread_pct=round(spread_pct, 2), - ) + response = ProductAutocompleteResponse(suggestions=suggestions) await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) + log_data = { + "query": q, + "elapsed_ms": round(elapsed_ms, 2), + "suggestions_count": len(suggestions), + "cache_hit": False, + } + if elapsed_ms > 50: + logger.warning("Autocomplete query slow", extra=log_data) + else: + logger.info("Autocomplete query executed", extra=log_data) + return response -@router.get("/trending", response_model=TrendingResponse, summary="Get trending products ranked by query volume and clicks") +@router.get("/trending", response_model=TrendingResponse, summary="Get trending products ranked by recent product-detail and compare activity") @limiter.limit(rate_limit_from_request) async def get_trending_products( request: Request, - period: str = Query("7d", pattern="^(24h|7d|30d)$", description="Trending period: 24h, 7d, or 30d"), + period: str = Query("7d", pattern="^(24h|7d)$", description="Trending period: 24h or 7d"), category: Optional[str] = Query(None, max_length=200, description="Filter by category name"), - limit: int = Query(50, ge=1, le=100, description="Number of products to return (1-100)"), + limit: int = Query(20, ge=1, le=100, description="Number of products to return (1-100)"), + currency: Optional[str] = Query(None, description=f"Target currency for price conversion. Supported: {', '.join(SUPPORTED_CURRENCIES)}"), db: AsyncSession = Depends(get_db), api_key: ApiKey = Depends(get_current_api_key), ) -> TrendingResponse: @@ -908,57 +1242,29 @@ async def get_trending_products( period=period, category=category, limit=limit, + currency=currency, ) cached = await cache.cache_get(cache_key) if cached: return TrendingResponse(**cached) - from datetime import datetime, timedelta, timezone - - period_hours = {"24h": 24, "7d": 24 * 7, "30d": 24 * 30}[period] - window_start = datetime.now(timezone.utc) - timedelta(hours=period_hours) - - view_counts = {} - click_counts = {} - - view_result = await db.execute( - select(ProductView.product_id, func.count(ProductView.id)) - .where(ProductView.viewed_at >= window_start) - .group_by(ProductView.product_id) - ) - for row in view_result.all(): - view_counts[row[0]] = row[1] - - click_result = await db.execute( - select(Click.product_id, func.count(Click.id)) - .where(Click.clicked_at >= window_start) - .group_by(Click.product_id) - ) - for row in click_result.all(): - click_counts[row[0]] = row[1] - - all_product_ids = set(view_counts.keys()) | set(click_counts.keys()) - - if not all_product_ids: - view_result_all = await db.execute( - select(ProductView.product_id, func.count(ProductView.id)) - .group_by(ProductView.product_id) + trend_scores = await get_trending_scores(period) + if not trend_scores: + response = TrendingResponse( + period=period, + category=category, + items=[], + total=0, + platform_distribution=[], + category_breakdown=[], ) - for row in view_result_all.all(): - view_counts[row[0]] = view_counts.get(row[0], 0) + row[1] - click_result_all = await db.execute( - select(Click.product_id, func.count(Click.id)) - .group_by(Click.product_id) - ) - for row in click_result_all.all(): - click_counts[row[0]] = click_counts.get(row[0], 0) + row[1] - all_product_ids = set(view_counts.keys()) | set(click_counts.keys()) - - combined_scores = {} - for pid in all_product_ids: - combined_scores[pid] = view_counts.get(pid, 0) + click_counts.get(pid, 0) + await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) + return response - sorted_product_ids = sorted(combined_scores.keys(), key=lambda x: combined_scores[x], reverse=True) + sorted_product_ids = sorted( + trend_scores.keys(), + key=lambda product_id: (-trend_scores[product_id], product_id), + ) if category: base_query = ( @@ -988,33 +1294,7 @@ async def get_trending_products( category_counts = {} items = [] for p in sorted_products[:limit]: - v_count = view_counts.get(p.id, 0) - c_count = click_counts.get(p.id, 0) - t_score = combined_scores.get(p.id, 0) - items.append(TrendingMatch( - id=p.id, - sku=p.sku, - source=p.source, - merchant_id=p.merchant_id, - name=p.title, - description=p.description, - price=p.price, - currency=p.currency, - buy_url=p.url, - affiliate_url=get_affiliate_url(p.source, p.url) if p.url else None, - image_url=p.image_url, - brand=p.brand, - category=p.category, - category_path=p.category_path, - rating=p.rating, - is_available=p.is_available, - last_checked=p.last_checked, - metadata=p.metadata_, - updated_at=p.updated_at, - view_count=v_count, - click_count=c_count, - trend_score=float(t_score), - )) + items.append(_map_product(p, target_currency=currency, trend_score=trend_scores.get(p.id, 0))) platform_counts[p.source] = platform_counts.get(p.source, 0) + 1 if p.category: category_counts[p.category] = category_counts.get(p.category, 0) + 1 @@ -1486,18 +1766,34 @@ async def get_product( cache_key = f"products:item:{product_id}:{currency or 'none'}" cached = await cache.cache_get(cache_key) if cached: - return ProductResponse(**cached) + response = ProductResponse(**cached) + else: + result = await db.execute( + select(Product).where(Product.id == product_id, Product.is_active == True) + ) + product = result.scalar_one_or_none() - result = await db.execute( - select(Product).where(Product.id == product_id, Product.is_active == True) - ) - product = result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") - if not product: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + response = _map_product(product, target_currency=currency) + await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=600) - response = _map_product(product, target_currency=currency) - await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=600) + await record_trending_product_event(product_id) + + etag = build_product_etag(response) + if etag_matches(request.headers.get("if-none-match"), etag): + headers = {"ETag": etag} + last_modified = build_last_modified(response.updated_at) + if last_modified: + headers["Last-Modified"] = last_modified + return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=headers) + + request.state.response_headers = getattr(request.state, "response_headers", {}) + request.state.response_headers["ETag"] = etag + last_modified = build_last_modified(response.updated_at) + if last_modified: + request.state.response_headers["Last-Modified"] = last_modified return response @@ -1512,15 +1808,26 @@ async def get_similar_products( request: Request, product_id: int, limit: int = Query(10, ge=1, le=50, description="Number of similar products to return (1-50)"), + currency: Optional[str] = Query(None, description=f"Target currency for price conversion. Supported: {', '.join(SUPPORTED_CURRENCIES)}"), db: AsyncSession = Depends(get_db), api_key: ApiKey = Depends(get_current_api_key), ) -> SimilarProductsResponse: request.state.api_key = api_key + if currency is not None and not isinstance(currency, str): + currency = None + + if currency is not None and currency not in SUPPORTED_CURRENCIES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported currency: {currency}. Supported: {', '.join(SUPPORTED_CURRENCIES)}", + ) + cache_key = cache.build_cache_key( "products:similar", product_id=product_id, limit=limit, + currency=currency or "none", ) cached = await cache.cache_get(cache_key) if cached: @@ -1536,7 +1843,7 @@ async def get_similar_products( if not source_product.category: response = SimilarProductsResponse(product_id=product_id, items=[], total=0) - await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) + await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=1800) return response source_price = _source_similar_price(source_product) @@ -1555,16 +1862,30 @@ async def get_similar_products( .order_by(func.abs(similar_price - source_price).asc(), Product.updated_at.desc()) .limit(limit) ) + result = await db.execute(query) candidates = result.scalars().all() + if not candidates: + fallback_query = ( + select(Product) + .where(Product.is_active == True) + .where(Product.id != product_id) + .where(Product.category == source_product.category) + .where(Product.merchant_id != source_product.merchant_id) + .order_by(func.abs(similar_price - source_price).asc(), Product.updated_at.desc()) + .limit(limit) + ) + fallback_result = await db.execute(fallback_query) + candidates = fallback_result.scalars().all() + response = SimilarProductsResponse( product_id=product_id, - items=[_map_product(product) for product in candidates], + items=[_map_product(product, target_currency=currency) for product in candidates], total=len(candidates), ) - await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) + await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=1800) return response @@ -1581,24 +1902,40 @@ async def batch_lookup_products( db: AsyncSession = Depends(get_db), api_key: ApiKey = Depends(get_current_api_key), ) -> BatchProductResponse: + """Bulk product lookup. + + - Accepts up to 50 product IDs via `ids` + - Supports sparse field selection via `fields` + - Returns ordered `products` and `not_found` arrays + - Requires Bearer or `X-API-Key` authentication + """ request.state.api_key = api_key - if len(body.product_ids) > 100: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Maximum 100 product IDs per request", - ) + request.state.usage_cost = len(body.ids) - cache_keys = [f"products:item:{pid}" for pid in body.product_ids] + numeric_id_map: Dict[str, int] = {} + invalid_ids: List[str] = [] + for raw_id in body.ids: + try: + numeric_id_map[raw_id] = int(raw_id) + except (TypeError, ValueError): + invalid_ids.append(raw_id) + + unique_numeric_ids = list(dict.fromkeys(numeric_id_map.values())) + cache_keys = [f"products:item:{pid}" for pid in unique_numeric_ids] cached_results = await cache.cache_get_many(cache_keys) - cached_products = {pid: cached_results.get(f"products:item:{pid}") for pid in body.product_ids if f"products:item:{pid}" in cached_results} + cached_products = { + pid: cached_results.get(f"products:item:{pid}") + for pid in unique_numeric_ids + if f"products:item:{pid}" in cached_results + } - product_ids_to_fetch = [pid for pid in body.product_ids if f"products:item:{pid}" not in cached_results] + product_ids_to_fetch = [pid for pid in unique_numeric_ids if f"products:item:{pid}" not in cached_results] - products: List[ProductResponse] = [] + products_by_id: Dict[int, ProductResponse] = {} for pid, cached in cached_products.items(): if cached: - products.append(ProductResponse(**cached)) + products_by_id[pid] = ProductResponse(**cached) if product_ids_to_fetch: result = await db.execute( @@ -1610,17 +1947,20 @@ async def batch_lookup_products( rows = result.scalars().all() for row in rows: product_response = _map_product(row) - products.append(product_response) + products_by_id[row.id] = product_response await cache.cache_set(f"products:item:{row.id}", product_response.model_dump(mode="json"), ttl_seconds=600) - found_ids = {p.id for p in products} - not_found = len(body.product_ids) - len(found_ids) - return BatchProductResponse( - products=products, - total=len(products), - found=len(products), - not_found=not_found, + products=[ + _project_batch_product(products_by_id[numeric_id_map[raw_id]], body.fields) + for raw_id in body.ids + if raw_id in numeric_id_map and numeric_id_map[raw_id] in products_by_id + ], + not_found=[ + raw_id + for raw_id in body.ids + if raw_id in invalid_ids or numeric_id_map.get(raw_id) not in products_by_id + ], ) @@ -1746,13 +2086,15 @@ async def get_product_matches( return response -@router.get("/{product_id}/price-history", response_model=PriceHistoryResponse, summary="Get price history for a product") +@router.get("/{product_id}/price-history", response_model=PriceHistoryResponse, summary="Get price history for a product — 30-day chart data") @limiter.limit(rate_limit_from_request) async def get_price_history( request: Request, product_id: int, days: int = Query(30, ge=1, le=365, description="Number of days of history to return (default 30)"), platform: Optional[str] = Query(None, description="Filter by source platform (e.g. shopee_sg, lazada_sg)"), + country_code: Optional[str] = Query(None, description="Filter by country code (e.g. SG, MY)"), + aggregate: Optional[str] = Query(None, description="Aggregation type: 'daily' for daily chart data"), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0, le=10000), db: AsyncSession = Depends(get_db), @@ -1765,6 +2107,8 @@ async def get_price_history( product_id=product_id, days=days, platform=platform, + country_code=country_code, + aggregate=aggregate, limit=limit, offset=offset, ) @@ -1775,12 +2119,80 @@ async def get_price_history( product_result = await db.execute( select(Product).where(Product.id == product_id, Product.is_active == True) ) - if not product_result.scalar_one_or_none(): + product = product_result.scalar_one_or_none() + if not product: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") from datetime import datetime, timedelta, timezone cutoff_date = datetime.now(timezone.utc) - timedelta(days=days) + if aggregate == "daily": + agg_query = select( + func.date(PriceHistory.recorded_at).label("date"), + func.min(PriceHistory.price).label("min_price"), + func.max(PriceHistory.price).label("max_price"), + func.avg(PriceHistory.price).label("avg_price"), + func.count(PriceHistory.id).label("price_count"), + PriceHistory.currency, + PriceHistory.source, + ).where( + PriceHistory.product_id == product_id, + PriceHistory.recorded_at >= cutoff_date, + ) + if platform: + agg_query = agg_query.where(PriceHistory.source == platform) + agg_query = agg_query.group_by( + func.date(PriceHistory.recorded_at), + PriceHistory.currency, + PriceHistory.source, + ).order_by(func.date(PriceHistory.recorded_at).desc()) + + agg_result = await db.execute(agg_query) + rows = agg_result.all() + + period_str = f"{days}d" + aggregated_entries = [ + PriceHistoryAggregationEntry( + date=str(row.date), + min_price=row.min_price, + max_price=row.max_price, + avg_price=row.avg_price, + price_count=row.price_count, + currency=row.currency, + platform=row.source, + ) + for row in rows + ] + + all_prices = [(float(r.max_price) + float(r.min_price)) / 2 for r in rows] + min_price_val = min((float(r.min_price) for r in rows), default=None) + max_price_val = max((float(r.max_price) for r in rows), default=None) + avg_price_val = sum(all_prices) / len(all_prices) if all_prices else None + + trend = "stable" + if len(all_prices) >= 2 and all_prices[0] is not None: + first_avg = (float(rows[0].min_price) + float(rows[0].max_price)) / 2 + last_avg = (float(rows[-1].min_price) + float(rows[-1].max_price)) / 2 + if last_avg > first_avg * 1.01: + trend = "up" + elif last_avg < first_avg * 0.99: + trend = "down" + + response = PriceHistoryResponse( + product_id=product_id, + merchant_id=product.merchant_id, + aggregated_entries=aggregated_entries, + total=len(aggregated_entries), + min_price=Decimal(str(round(min_price_val, 2))) if min_price_val is not None else None, + max_price=Decimal(str(round(max_price_val, 2))) if max_price_val is not None else None, + avg_price=Decimal(str(round(avg_price_val, 2))) if avg_price_val is not None else None, + trend=trend, + aggregate="daily", + period=period_str, + ) + await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) + return response + query = select(PriceHistory).where(PriceHistory.product_id == product_id) query = query.where(PriceHistory.recorded_at >= cutoff_date) @@ -1794,20 +2206,46 @@ async def get_price_history( query = query.order_by(PriceHistory.recorded_at.desc()).limit(limit).offset(offset) history_result = await db.execute(query) + raw_entries = history_result.scalars().all() entries = [ PriceHistoryEntry( + date=h.recorded_at.strftime("%Y-%m-%d") if h.recorded_at else None, price=h.price, currency=h.currency, platform=h.source, + in_stock=None, scraped_at=h.recorded_at, ) - for h in history_result.scalars().all() + for h in raw_entries ] + all_prices = [float(h.price) for h in raw_entries] + min_price_val = min(all_prices) if all_prices else None + max_price_val = max(all_prices) if all_prices else None + avg_price_val = sum(all_prices) / len(all_prices) if all_prices else None + + trend = "stable" + if len(raw_entries) >= 2: + first_price = float(raw_entries[-1].price) + last_price = float(raw_entries[0].price) + if last_price > first_price * 1.01: + trend = "up" + elif last_price < first_price * 0.99: + trend = "down" + + period_str = f"{days}d" + response = PriceHistoryResponse( product_id=product_id, + merchant_id=product.merchant_id, + data_points=entries, entries=entries, total=total, + min_price=Decimal(str(round(min_price_val, 2))) if min_price_val is not None else None, + max_price=Decimal(str(round(max_price_val, 2))) if max_price_val is not None else None, + avg_price=Decimal(str(round(avg_price_val, 2))) if avg_price_val is not None else None, + trend=trend, + period=period_str, ) await cache.cache_set(cache_key, response.model_dump(mode="json"), ttl_seconds=300) return response diff --git a/app/schemas/product.py b/app/schemas/product.py index c94811f48..11c35f4ec 100644 --- a/app/schemas/product.py +++ b/app/schemas/product.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import Optional, List, Any, Dict from decimal import Decimal from datetime import datetime @@ -13,9 +13,11 @@ class PriceTrend(str): class PriceHistoryEntry(BaseModel): + date: Optional[str] = Field(None, description="Date in YYYY-MM-DD format (derived from scraped_at for chart compatibility)") price: Decimal currency: str platform: str = Field(..., description="Source platform (e.g. shopee_sg, lazada_sg)") + in_stock: Optional[bool] = Field(None, description="Whether product was in stock on this date (if available)") scraped_at: datetime = Field(..., description="Timestamp when price was scraped") model_config = {"from_attributes": True, "populate_by_name": True} @@ -35,12 +37,20 @@ class PriceHistoryAggregationEntry(BaseModel): class PriceHistoryResponse(BaseModel): product_id: int + merchant_id: Optional[str] = Field(None, description="Merchant/platform of the product") + data_points: List[PriceHistoryEntry] = Field(default_factory=list, description="Raw price data points for charting") entries: List[PriceHistoryEntry] = Field(default_factory=list) aggregated_entries: List[PriceHistoryAggregationEntry] = Field(default_factory=list, description="Aggregated daily time series when aggregate=daily is requested") total: int + min_price: Optional[Decimal] = Field(None, description="Minimum price across the period") + max_price: Optional[Decimal] = Field(None, description="Maximum price across the period") + avg_price: Optional[Decimal] = Field(None, description="Average price across the period") + trend: Optional[str] = Field(None, description="Price trend: 'up', 'down', or 'stable'") aggregate: Optional[str] = Field(None, description="Aggregation type: 'daily' if aggregated") period: Optional[str] = Field(None, description="Period requested (e.g. '30d')") + model_config = {"from_attributes": True, "populate_by_name": True} + class PriceStats(BaseModel): current_price: Decimal @@ -70,6 +80,7 @@ class ProductResponse(BaseModel): price: Decimal currency: str price_sgd: Optional[Decimal] = Field(None, description="Price normalized to SGD") + usd_price: Optional[Decimal] = Field(None, description="Price normalized to USD") converted_price: Optional[Decimal] = Field(None, description="Price converted to requested currency (when ?currency= param is used)") converted_currency: Optional[str] = Field(None, description="Currency of converted_price") buy_url: str = Field(..., description="Direct purchase URL") @@ -97,6 +108,7 @@ class ProductResponse(BaseModel): metadata: Optional[Any] = None updated_at: datetime price_trend: Optional[str] = Field(None, description="30-day price trend: 'up', 'down', or 'stable'") + trend_score: Optional[int] = Field(None, description="Rolling trending score based on recent product-detail and compare activity") class SearchFiltersResponse(BaseModel): @@ -112,11 +124,12 @@ class SearchFiltersResponse(BaseModel): class ProductListResponse(BaseModel): total: int + total_count: Optional[int] = Field(None, description="Total matching products across all pages") limit: int offset: int items: List[ProductResponse] has_more: bool = False - next_cursor: Optional[int] = None + next_cursor: Optional[str] = Field(None, description="Opaque cursor for the next page") facets: Optional[Any] = None highlights: Optional[Dict[str, str]] = None @@ -177,6 +190,10 @@ class SearchSuggestionResponse(BaseModel): total: int +class ProductAutocompleteResponse(BaseModel): + suggestions: List[str] + + class AutocompleteResponse(BaseModel): query: str suggestions: List[Any] @@ -262,6 +279,8 @@ class CompareMatch(BaseModel): price_missing_reason: Optional[PriceMissingReason] = Field(None, description="Reason why price is missing") zero_price: bool = Field(False, description="True when product is free (price is 0)") data_freshness: Optional[str] = Field(None, description="Data freshness tier: fresh (<24h), recent (24h-7d), stale (7-30d), very_stale (>30d)") + low_confidence: bool = Field(False, description="True when the listing matched below the compare confidence audit threshold") + confidence_audit_threshold: Optional[float] = Field(None, description="Threshold used to determine whether low_confidence is set") merchant_edge_case: Optional[str] = Field( None, description="Edge case flag for merchant issues: 'duplicate_merchant', 'empty_merchant_id', 'unknown_merchant', 'cross_border_merchant', or null if no issues" @@ -279,7 +298,7 @@ class CompareResponse(BaseModel): source_product_name: str matches: List[Any] total_matches: int - highlights: CompareHighlights + highlights: Optional[CompareHighlights] = Field(None, description="Highlighted matches (cheapest, best rated, fastest shipping)") meta: Optional[Dict[str, Any]] = Field(None, description="Metadata including price_coverage_pct, stale_count, very_stale_count") @@ -327,6 +346,7 @@ class SimilarMatch(BaseModel): updated_at: Optional[datetime] = None similarity_score: float match_reasons: List[str] = Field(default_factory=list) + price_delta: Optional[str] = Field(None, description="Price difference vs source product, e.g. '34% cheaper'") model_config = {"from_attributes": True} @@ -426,6 +446,7 @@ class RecommendationMatch(BaseModel): metadata: Optional[Any] = None updated_at: Optional[datetime] = None relevance_score: Optional[float] = None + price_delta: Optional[str] = Field(None, description="Price difference vs source product, e.g. '34% cheaper'") class BundleMatch(BaseModel): @@ -546,6 +567,11 @@ class ApiKeyResponse(BaseModel): created_at: datetime last_used_at: Optional[datetime] = None expires_at: Optional[datetime] = None + utm_source: Optional[str] = None + utm_medium: Optional[str] = None + utm_campaign: Optional[str] = None + utm_content: Optional[str] = None + utm_term: Optional[str] = None model_config = {"from_attributes": True} @@ -606,6 +632,7 @@ class RatingFacetBucket(BaseModel): class FacetCounts(BaseModel): categories: List[FacetBucket] = Field(default_factory=list) platforms: List[FacetBucket] = Field(default_factory=list) + retailers: List[FacetBucket] = Field(default_factory=list) brands: List[FacetBucket] = Field(default_factory=list) rating_ranges: List[RatingFacetBucket] = Field(default_factory=list) price_ranges: List[PriceFacetBucket] = Field(default_factory=list) @@ -640,12 +667,14 @@ class DeveloperSignupResponse(BaseModel): name: Optional[str] = None tier: Optional[str] = None message: str = "" + redirect_url: Optional[str] = None class DeveloperResponse(BaseModel): id: str email: str plan: str + email_verified: bool = False created_at: datetime model_config = {"from_attributes": True} @@ -655,6 +684,11 @@ class DeveloperMeResponse(BaseModel): developer: DeveloperResponse api_keys: List[ApiKeyResponse] total_keys: int + requests_today: int + daily_limit: int + requests_this_month: int + monthly_limit: int + reset_at: datetime class EndpointUsage(BaseModel): @@ -677,13 +711,36 @@ class UsageStats(BaseModel): alert_triggered: bool +class TopQueryUsage(BaseModel): + query: str + count: int + + class DeveloperUsageResponse(BaseModel): - developer_id: str - key_id: str - key_name: str - tier: str - usage: UsageStats - alert_config: dict + requests_today: int + requests_this_month: int + rate_limit_hits: int + top_5_queries: List[TopQueryUsage] + + +class ApiKeyStats(BaseModel): + key_prefix: str + last_used: Optional[datetime] = None + + +class EndpointUsageSimple(BaseModel): + endpoint: str + count: int + + +class DeveloperStatsResponse(BaseModel): + plan: str + requests_today: int + requests_7d: int + daily_limit: int + reset_at: datetime + top_endpoints: List[EndpointUsageSimple] + api_keys: List[ApiKeyStats] class BundleResponse(BaseModel): @@ -783,7 +840,10 @@ class MerchantSummary(BaseModel): merchant_id: str = Field(..., description="Unique merchant identifier") merchant_name: str = Field(..., description="Merchant/store name") platform: str = Field(..., description="Source platform (e.g. shopee_sg, lazada_sg)") + country: Optional[str] = Field(None, description="ISO 3166-1 alpha-2 country code for the merchant") product_count: int = Field(..., description="Number of active products from this merchant") + affiliate_enabled: bool = Field(False, description="Whether BuyWhere can generate an affiliate/tracked link for this merchant") + example_url: Optional[str] = Field(None, description="Example product URL from this merchant") categories: List[str] = Field(default_factory=list, description="Distinct product categories from this merchant") avg_rating: Optional[float] = Field(None, description="Average product rating across merchant's products (0-5 scale)") last_scraped_at: Optional[datetime] = Field(None, description="Timestamp of most recently scraped product from this merchant") @@ -797,6 +857,38 @@ class MerchantListResponse(BaseModel): has_more: bool = False +class ProductSummaryForMerchant(BaseModel): + id: int + name: str + price: Decimal + currency: str + image_url: Optional[str] = None + url: str + rating: Optional[Decimal] = None + in_stock: Optional[bool] = None + + model_config = {"from_attributes": True} + + +class MerchantDetail(BaseModel): + merchant_id: str = Field(..., description="Unique merchant identifier") + merchant_name: str = Field(..., description="Merchant/store name") + platform: str = Field(..., description="Source platform (e.g. shopee_sg, lazada_sg)") + country: Optional[str] = Field(None, description="ISO 3166-1 alpha-2 country code for the merchant") + product_count: int = Field(..., description="Number of active products from this merchant") + affiliate_enabled: bool = Field(False, description="Whether BuyWhere can generate an affiliate/tracked link for this merchant") + example_url: Optional[str] = Field(None, description="Example product URL from this merchant") + categories: List[str] = Field(default_factory=list, description="Distinct product categories from this merchant") + top_categories: List[str] = Field(default_factory=list, description="Top merchant categories ranked by active product count") + avg_rating: Optional[float] = Field(None, description="Average product rating across merchant's products (0-5 scale)") + last_scraped_at: Optional[datetime] = Field(None, description="Timestamp of most recently scraped product from this merchant") + sample_products: List[ProductSummaryForMerchant] = Field(default_factory=list, description="Sample of active products from this merchant") + + +class MerchantDetailResponse(BaseModel): + merchant: MerchantDetail + + class BulkLookupMatch(BaseModel): id: int sku: str @@ -832,14 +924,46 @@ class BulkLookupResponse(BaseModel): class BatchProductRequest(BaseModel): - product_ids: List[int] = Field(..., min_length=1, max_length=100, description="List of product IDs to look up (max 100)") + ids: List[str] = Field(..., min_length=1, max_length=50, description="List of product IDs to look up (max 50)") + fields: List[str] = Field( + default_factory=lambda: ["id", "name", "price", "currency", "url"], + min_length=1, + description="Fields to include in each product result", + ) + + @field_validator("ids", mode="before") + @classmethod + def normalize_ids(cls, value): + if not isinstance(value, list): + raise TypeError("ids must be a list") + normalized = [] + for item in value: + text = str(item).strip() + if not text: + raise ValueError("ids cannot contain empty values") + normalized.append(text) + return normalized + + @field_validator("fields") + @classmethod + def validate_fields(cls, value: List[str]) -> List[str]: + allowed = set(ProductResponse.model_fields.keys()) | {"url"} + normalized = [] + for field_name in value: + field = field_name.strip() + if field not in allowed: + raise ValueError(f"Unsupported field: {field}") + normalized.append(field) + return normalized class BatchProductResponse(BaseModel): - products: List[ProductResponse] - total: int - found: int - not_found: int + products: List[Dict[str, Any]] + not_found: List[str] + + +class V2BatchProductRequest(BaseModel): + product_ids: List[int] = Field(..., min_length=1, max_length=100, description="List of product IDs to look up (max 100)") class BulkIdsRequest(BaseModel): @@ -976,6 +1100,7 @@ class V2ProductResponse(BaseModel): price: Decimal currency: str price_sgd: Optional[Decimal] = None + usd_price: Optional[Decimal] = None region: str = Field("sg", description="Geographic region (e.g. sg, us, sea, eu, au)") country_code: str = Field("SG", description="ISO 3166-1 alpha-2 country code") buy_url: str @@ -1079,3 +1204,73 @@ class BulkProductResponse(BaseModel): success_count: int = Field(..., description="Number of successful upserts") failure_count: int = Field(..., description="Number of failed upserts") results: List[BulkUpsertResult] = Field(default_factory=list, description="Individual upsert results") + + +class PriceVariantItem(BaseModel): + id: int + source: str + merchant_id: str + merchant_name: Optional[str] = Field(None, description="Human-readable merchant/store name") + price: Decimal + currency: str + price_sgd: Optional[Decimal] = None + usd_price: Optional[Decimal] = None + buy_url: str + affiliate_url: Optional[str] = Field(None, description="Tracked affiliate URL") + is_available: bool + in_stock: Optional[bool] = None + stock_level: Optional[str] = None + rating: Optional[Decimal] = None + review_count: Optional[int] = None + image_url: Optional[str] = None + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ProductDetailResponse(BaseModel): + sku: str + canonical_id: Optional[int] = None + name: str + description: Optional[str] = None + brand: Optional[str] = None + category: Optional[str] = None + category_path: Optional[List[str]] = None + image_url: Optional[str] = None + barcode: Optional[str] = None + specs: Optional[Dict[str, Any]] = None + lowest_price: Decimal = Field(..., description="Lowest price across all variants") + highest_price: Decimal = Field(..., description="Highest price across all variants") + currency: str + price_sgd: Optional[Decimal] = None + usd_price: Optional[Decimal] = None + variants: List[PriceVariantItem] = Field(default_factory=list, description="All price variants from different merchants") + total_merchants: int = Field(..., description="Number of merchants offering this SKU") + updated_at: datetime + + model_config = {"from_attributes": True} + + +class BestPriceItem(BaseModel): + price: Decimal = Field(..., description="Product price") + currency: str = Field(..., description="Currency code (e.g., USD)") + retailer: str = Field(..., description="Retailer/source identifier (e.g., walmart_us)") + url: str = Field(..., description="Direct purchase URL") + + model_config = {"from_attributes": True} + + +class SavingsInfo(BaseModel): + amount: Decimal = Field(..., description="Savings amount compared to highest price") + percent: float = Field(..., description="Savings as percentage") + + model_config = {"from_attributes": True} + + +class BestPriceResponse(BaseModel): + product_id: int = Field(..., description="Original product ID used for the query") + best_price: BestPriceItem = Field(..., description="Cheapest price found") + all_prices: List[BestPriceItem] = Field(default_factory=list, description="All prices from US retailers") + savings: SavingsInfo = Field(..., description="Savings compared to highest price") + + model_config = {"from_attributes": True} diff --git a/app/services/typesense_search.py b/app/services/typesense_search.py new file mode 100644 index 000000000..aa132a898 --- /dev/null +++ b/app/services/typesense_search.py @@ -0,0 +1,126 @@ +"""Typesense-backed fast search for product catalog. + +Returns product UUIDs from Typesense (8-25ms), which the caller then +hydrates from Postgres. Falls back gracefully when Typesense is unavailable +or returns no results. +""" +from __future__ import annotations + +import os +from typing import Optional + +import httpx + +from app.logging_centralized import get_logger + +logger = get_logger("typesense-search-service") + +_TYPESENSE_URL = os.getenv("TYPESENSE_URL", "http://buywhere-typesense-1:8108") +_TYPESENSE_API_KEY = os.getenv("TYPESENSE_API_KEY", "") +_COLLECTION = "products" +_TIMEOUT = 5.0 # seconds — fast or fallback + +# Shared async client (module-level singleton, thread-safe for asyncio) +_client: Optional[httpx.AsyncClient] = None + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + base_url=_TYPESENSE_URL, + headers={"X-TYPESENSE-API-KEY": _TYPESENSE_API_KEY}, + timeout=_TIMEOUT, + ) + return _client + + +async def typesense_search( + q: str, + *, + category: Optional[str] = None, + platform: Optional[str] = None, + price_min: Optional[float] = None, + price_max: Optional[float] = None, + limit: int = 20, + offset: int = 0, +) -> Optional[tuple[list[str], int]]: + """Search Typesense and return (product_ids, found_count). + + Returns None on any error so the caller can fall back to Postgres. + """ + if not _TYPESENSE_API_KEY: + return None + + params: dict = { + "q": q, + "query_by": "name,description,brand", + "per_page": min(limit, 250), + "page": (offset // limit) + 1 if limit > 0 else 1, + "include_fields": "id", + } + + filter_parts: list[str] = ["availability:=true"] + if category: + filter_parts.append(f"category_path:={category}") + if platform: + filter_parts.append(f"platform:={platform}") + if price_min is not None: + filter_parts.append(f"price:>={price_min}") + if price_max is not None: + filter_parts.append(f"price:<={price_max}") + if filter_parts: + params["filter_by"] = " && ".join(filter_parts) + + try: + client = _get_client() + resp = await client.get( + f"/collections/{_COLLECTION}/documents/search", + params=params, + ) + resp.raise_for_status() + data = resp.json() + ids = [hit["document"]["id"] for hit in data.get("hits", [])] + found = data.get("found", len(ids)) + return ids, found + except Exception as exc: + logger.warning("Typesense search failed, falling back to Postgres: %s", exc) + return None + + +async def typesense_autocomplete( + q: str, + *, + limit: int = 10, +) -> Optional[tuple[list[str], int]]: + """Fast prefix autocomplete from Typesense. + + Returns (suggestion_strings, found_count) on success, None on failure. + Uses prefix query on product names for fast suggestion completion. + """ + if not _TYPESENSE_API_KEY: + return None + + params: dict = { + "q": q, + "query_by": "name", + "prefix_search": "true", + "per_page": limit, + "include_fields": "name", + } + + try: + client = _get_client() + resp = await client.get( + f"/collections/{_COLLECTION}/documents/search", + params=params, + ) + resp.raise_for_status() + data = resp.json() + names = [hit["document"]["name"] for hit in data.get("hits", [])] + found = data.get("found", len(names)) + deduped = list(dict.fromkeys(names)) + return deduped, found + except Exception as exc: + logger.warning("Typesense autocomplete failed, falling back to Postgres: %s", exc) + return None diff --git a/reports/buy-4353-owasp-audit-2026-04-25.md b/reports/buy-4353-owasp-audit-2026-04-25.md new file mode 100644 index 000000000..f15b6c3a0 --- /dev/null +++ b/reports/buy-4353-owasp-audit-2026-04-25.md @@ -0,0 +1,186 @@ +# BUY-4353 OWASP Top 10 Audit + +Date: 2026-04-25 +Repo: `buywhere-api` +Commit reviewed: `a5781aa9` +Auditor: Zeno + +## Scope + +- `app/main.py` +- `app/routers/` +- `app/auth.py` +- `app/request_logging.py` +- `requirements.txt` + +Note: the assigned Codex workspace was empty for this heartbeat. The audit was performed against the matching FastAPI repository at `/home/paperclip/buywhere-api`. + +## Executive Summary + +The API has two high-severity access control/authentication flaws: + +1. Any valid API key can access `/admin/*` data endpoints, including developer PII, API key metadata, usage metrics, and request logs. +2. `POST /v1/keys` provisions new API keys using a static secret derived from `jwt_secret_key`, and that secret remains the default string `change-me-in-production` when the env var is unset. + +Additional medium-severity issues exist in CORS configuration, bootstrap-key protection, unauthenticated operational webhooks, error logging, and dependency hygiene. + +## Findings + +### High + +#### 1. Broken access control on `/admin/*` endpoints + +- CWE: CWE-862 / CWE-284 +- OWASP: A01 Broken Access Control +- Evidence: + - [`app/routers/admin.py:115`](/home/paperclip/buywhere-api/app/routers/admin.py:115) through [`app/routers/admin.py:163`](/home/paperclip/buywhere-api/app/routers/admin.py:163) gate admin routes with `Depends(get_current_api_key)` only. + - [`app/routers/admin.py:692`](/home/paperclip/buywhere-api/app/routers/admin.py:692) and [`app/routers/admin.py:755`](/home/paperclip/buywhere-api/app/routers/admin.py:755) expose the full developer list and per-developer detail, including email, Stripe fields, API key metadata, and request counts, again with only `get_current_api_key`. + - [`app/routers/feature_flags.py:19`](/home/paperclip/buywhere-api/app/routers/feature_flags.py:19) already defines a proper `get_current_admin_api_key` tier check, which the main admin router does not use. +- Impact: + - Any customer or leaked API key can read internal operator data, developer emails, API key inventory, usage analytics, and session/log data across tenants. + - This is a direct IDOR / privilege-escalation path from normal developer access into operator-only data. +- Remediation: + - Apply a shared `get_current_admin_api_key` dependency to the entire `/admin` router or split operator-only routes behind a dedicated router with enforced tier checks. + - Add negative tests proving `free`/`pro` keys receive `403` for every `/admin/*` endpoint. + +#### 2. Internal API-key provisioning protected by a predictable secret + +- CWE: CWE-798 / CWE-306 +- OWASP: A07 Identification and Authentication Failures +- Evidence: + - [`app/routers/keys.py:19`](/home/paperclip/buywhere-api/app/routers/keys.py:19) sets `ADMIN_SECRET = settings.jwt_secret_key`. + - [`app/routers/keys.py:36`](/home/paperclip/buywhere-api/app/routers/keys.py:36) exposes `POST /v1/keys` without normal authentication and authorizes purely by comparing `body.admin_secret` to that static value. + - [`app/main.py:54`](/home/paperclip/buywhere-api/app/main.py:54) mutates `settings.jwt_secret_key` during app startup if the env var is unset, but that happens after `ADMIN_SECRET` has already been bound in `keys.py`, leaving the guard value as the literal default string. +- Impact: + - In any deployment missing `JWT_SECRET_KEY`, an attacker can provision arbitrary API keys by posting `admin_secret=change-me-in-production`. + - Even when `JWT_SECRET_KEY` is set, coupling an internal provisioning secret to the JWT signing secret increases blast radius and creates a single-secret compromise path. +- Remediation: + - Remove `POST /v1/keys` from public routing or require authenticated admin-tier credentials. + - If a separate bootstrap secret is still needed, use a dedicated env var with startup validation that fails closed when unset. + - Add a startup check that refuses to boot with default auth secrets in non-test environments. + +### Medium + +#### 3. CORS misconfiguration allows wildcard origins with credentials + +- CWE: CWE-942 +- OWASP: A05 Security Misconfiguration +- Evidence: + - [`app/main.py:85`](/home/paperclip/buywhere-api/app/main.py:85) configures `allow_origins=["*"]` together with `allow_credentials=True`. +- Impact: + - This is an unsafe browser-facing policy and typically invalid per CORS semantics. It risks accidental cross-origin exposure if browsers or proxies handle it inconsistently, especially for user-token flows under `/api/auth/*`. +- Remediation: + - Replace `*` with an explicit allowlist and disable credentials for public unauthenticated endpoints that do not require browser cookies or auth headers. + +#### 4. Bootstrap-key endpoint trusts the `Host` header instead of the client origin + +- CWE: CWE-346 +- OWASP: A01 Broken Access Control / A05 Security Misconfiguration +- Evidence: + - [`app/routers/keys.py:75`](/home/paperclip/buywhere-api/app/routers/keys.py:75) exposes `POST /v1/keys/bootstrap`. + - [`app/routers/keys.py:91`](/home/paperclip/buywhere-api/app/routers/keys.py:91) authorizes bootstrap if the `Host` header starts with `127.0.0.1` or `localhost`. +- Impact: + - Reverse proxies can forward attacker-controlled `Host` headers, which makes this check spoofable if the route is reachable externally during first-run/bootstrap. +- Remediation: + - Remove the route from production builds, or gate it behind an out-of-band bootstrap token plus real network-layer restrictions. + - If locality is required, validate the peer address from trusted proxy headers only after strict proxy configuration. + +#### 5. Unauthenticated Alertmanager webhook can create internal Paperclip issues + +- CWE: CWE-306 +- OWASP: A01 Broken Access Control / A05 Security Misconfiguration +- Evidence: + - [`app/routers/alertmanager_webhooks.py:131`](/home/paperclip/buywhere-api/app/routers/alertmanager_webhooks.py:131) exposes `POST /webhooks/alerts` with no signature, auth, IP allowlist, or shared secret verification. + - The handler can call Paperclip and create high-priority issues using server-side credentials at [`app/routers/alertmanager_webhooks.py:103`](/home/paperclip/buywhere-api/app/routers/alertmanager_webhooks.py:103). +- Impact: + - Any external caller able to reach this endpoint can trigger noisy operational actions and potentially create unbounded internal work items. +- Remediation: + - Require an HMAC signature or mTLS from Alertmanager, and ideally restrict ingress by source IP/CIDR at the edge. + - Rate-limit and deduplicate alert-triggered issue creation. + +#### 6. Error logging records raw exception messages + +- CWE: CWE-209 / CWE-532 +- OWASP: A02 Cryptographic Failures / A09 Security Logging and Monitoring Failures +- Evidence: + - [`app/request_logging.py:265`](/home/paperclip/buywhere-api/app/request_logging.py:265) and [`app/request_logging.py:280`](/home/paperclip/buywhere-api/app/request_logging.py:280) write `str(exc)` into structured logs. +- Impact: + - Exception strings often contain SQL fragments, third-party responses, tokens in failing URLs, or internal validation payloads. This increases sensitive-data exposure in logs. +- Remediation: + - Log stable error codes/types by default and redact or hash known-sensitive values before emission. + - Keep full exception bodies only in tightly controlled observability backends with explicit redaction. + +### Low + +#### 7. Default-secret startup behavior avoids weak JWT signing at runtime, but fails open operationally + +- CWE: CWE-1188 +- OWASP: A05 Security Misconfiguration +- Evidence: + - [`app/main.py:54`](/home/paperclip/buywhere-api/app/main.py:54) replaces the default JWT secret with an ephemeral random key instead of failing startup. +- Impact: + - This is better than serving traffic with the literal default secret, but it silently invalidates sessions after restarts and masks a production misconfiguration that should block deploys. +- Remediation: + - Fail fast in non-test environments when `JWT_SECRET_KEY` is unset or defaulted. + +## Injection Review + +- I did not find a clear exploitable SQL injection path in the sampled raw SQL. The inspected search and ingest queries generally use SQLAlchemy expressions or `text(...).bindparams(...)`. +- No direct `subprocess`, `os.system`, or `shell=True` execution paths were identified in the audited FastAPI request handlers. +- This should still be regression-tested because the codebase contains many ad hoc SQL expressions, and future string interpolation into `text()` would be easy to introduce. + +## Dependency Audit + +Command run: + +```bash +/tmp/buywhere-pip-audit/bin/pip-audit -r requirements.txt +``` + +Result: `24` known vulnerabilities across `4` packages. + +Packages with findings: + +- `lxml==6.0.2` + - `CVE-2026-41066` + - Fix: `6.1.0` +- `python-multipart==0.0.24` + - `CVE-2026-40347` + - Fix: `0.0.26` +- `strawberry-graphql==0.243.0` + - `CVE-2025-22151` + - `CVE-2026-35526` + - `CVE-2026-35523` + - Fix: `0.257.0` minimum for the first advisory, `0.312.3` for full coverage +- `aiohttp==3.11.18` + - `CVE-2025-53643` + - `CVE-2025-69223` + - `CVE-2025-69224` + - `CVE-2025-69225` + - `CVE-2025-69226` + - `CVE-2025-69227` + - `CVE-2025-69228` + - `CVE-2025-69229` + - `CVE-2025-69230` + - `CVE-2026-22815` + - `CVE-2026-34513` + - `CVE-2026-34514` + - `CVE-2026-34515` + - `CVE-2026-34516` + - `CVE-2026-34517` + - `CVE-2026-34518` + - `CVE-2026-34519` + - `CVE-2026-34520` + - `CVE-2026-34525` + - Fix: `3.13.4` + +## Recommended Next Actions + +1. Treat `/admin/*` authorization and `/v1/keys` provisioning as immediate fixes before broader public rollout. +2. Lock down operational webhooks and bootstrap paths. +3. Upgrade `aiohttp`, `strawberry-graphql`, `python-multipart`, and `lxml`, then rerun `pip-audit`. +4. Add a security test suite covering: + - admin route denial for non-admin keys + - secret/bootstrap route denial in production config + - webhook signature enforcement + - startup failure on default secrets diff --git a/sdk/npm/README.md b/sdk/npm/README.md new file mode 100644 index 000000000..c4a0ddcbf --- /dev/null +++ b/sdk/npm/README.md @@ -0,0 +1,405 @@ +# @buywhere/sdk + +Official TypeScript SDK for [BuyWhere](https://buywhere.ai) — the agent-native product catalog API for AI agent commerce in Singapore and globally. + +## Installation + +```bash +npm install @buywhere/sdk +``` + +Requires Node.js 18 or later. + +## Quick Start + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +const results = await client.search({ + q: "wireless headphones", + limit: 5, + in_stock: true, +}); + +console.log(`Found ${results.total} products`); +for (const product of results.items) { + console.log(`${product.name} | ${product.currency} ${product.price} | ${product.source}`); +} +``` + +Set your API key before running examples: + +```bash +export BUYWHERE_API_KEY="bw_live_your_key_here" +``` + +Or pass it directly: + +```typescript +const client = new BuyWhereClient("bw_live_your_key_here"); +``` + +## Usage Examples + +### Search and fetch a product + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +const results = await client.search({ + q: "vitamin c serum", + category: "Health & Beauty", + limit: 10, + in_stock: true, +}); + +const first = results.items[0]; +if (!first) { + throw new Error("No matching products found"); +} + +console.log("Top match:", first.name, first.price, first.currency); + +const product = await client.getProduct(first.id); +console.log("Buy URL:", product.affiliate_url ?? product.buy_url); +``` + +### Compare merchants for a known product + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +const search = await client.search({ q: "Nintendo Switch OLED", limit: 1 }); +const product = search.items[0]; + +if (!product) { + throw new Error("Product not found"); +} + +const comparison = await client.compare({ product_id: product.id }); + +console.log(`Compared ${comparison.total_matches} listings for ${comparison.source_product_name}`); + +for (const match of comparison.matches.slice(0, 5)) { + console.log(`${match.source}: ${match.currency} ${match.price}`); +} + +if (comparison.highlights?.cheapest) { + console.log( + "Cheapest:", + comparison.highlights.cheapest.source, + comparison.highlights.cheapest.price + ); +} +``` + +### Track deals feed + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +const deals = await client.getDeals({ + category: "Electronics", + min_discount_pct: 25, + limit: 20, +}); + +for (const deal of deals.items) { + console.log(`${deal.name} dropped ${deal.discount_pct}% to ${deal.currency} ${deal.price}`); +} +``` + +### Resolve the final outbound URL + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +const resolved = await client.resolveAffiliate(78234, { trackClick: true }); +console.log(resolved.resolved_url); +``` + +### Subscribe to new deals + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +const { stop } = await client.subscribe( + (deal) => console.log("New deal:", deal.name), + { category: "Health & Beauty", min_discount_pct: 30 }, + 60000 +); + +setTimeout(() => stop(), 5 * 60 * 1000); +``` + +### Runnable repo examples + +The repository includes runnable scripts in `sdk/npm/examples`: + +```bash +npm install +npm run example:search -- "mechanical keyboard" +npm run example:get-product -- 78234 +npm run example:compare -- "Nintendo Switch OLED" +npm run example:price-history -- "iPhone 15" 30 +BUYWHERE_WEBHOOK_URL="https://example.com/webhooks/buywhere" npm run example:webhook +``` + +`example:price-history` and `example:webhook` use raw HTTP for now because the current TypeScript client does not yet expose dedicated helper methods for those endpoints. + +## API Reference + +### `new BuyWhereClient(apiKey, options?)` + +Create a new client instance. + +| Option | Type | Default | +|--------|------|---------| +| `baseUrl` | `string` | `https://api.buywhere.ai` | +| `timeout` | `number` | `30000` | +| `maxRetries` | `number` | `3` | +| `retryDelay` | `number` | `1000` | +| `backoffMultiplier` | `number` | `2` | + +### Methods + +#### `search(options?)` +Search for products. + +```typescript +const results = await client.search({ + q: "query string", + category: "Health", + min_price: 10, + max_price: 100, + source: "guardian_sg", + in_stock: true, + limit: 20, + offset: 0, +}); +``` + +Returns `ProductListResponse` with `total`, `limit`, `offset`, `has_more`, and `items`. + +#### `getProduct(productId)` +Get a single product by ID. + +```typescript +const product = await client.getProduct(12345); +``` + +#### `resolveAffiliate(productId, options?)` +Resolve the best outbound URL for a product, optionally recording a tracked click. + +```typescript +const resolved = await client.resolveAffiliate(12345, { trackClick: true }); +console.log(resolved.resolved_url); +``` + +#### `compare(options)` +Compare a product across merchants. + +```typescript +const comparison = await client.compare({ + product_id: 12345, + min_price: 10, + max_price: 100, +}); +``` + +#### `compareSearch(options)` +Search for comparable listings without looking up a product ID first. + +```typescript +const comparison = await client.compareSearch({ + q: "Dyson V12 cordless vacuum", + limit: 10, +}); +``` + +#### `compareProductById(productId, options?)` +Compare a product with the `/v1/compare/{productId}` endpoint. + +```typescript +const comparison = await client.compareProductById(12345, { + min_price: 50, + max_price: 500, +}); +``` + +#### `compareProducts(options)` +Compare multiple products at once. + +```typescript +const matrix = await client.compareProducts({ + product_ids: [123, 456, 789], + min_price: 10, + max_price: 100, +}); +``` + +#### `compareProductsDiff(options)` +Get detailed diff between products. + +```typescript +const diff = await client.compareProductsDiff({ + product_ids: [123, 456], + include_image_similarity: true, +}); +``` + +#### `getDeals(options?)` +Get current deals/discounts. + +```typescript +const deals = await client.getDeals({ + category: "Health", + min_discount_pct: 30, + limit: 50, +}); +``` + +#### `trending(period?, category?, limit?)` +Get trending products. + +```typescript +const trending = await client.trending("7d", "Skin Care", 50); +``` + +#### `categories()` +Get all available categories. + +```typescript +const { categories } = await client.categories(); +``` + +#### `changelog()` +Fetch recent API release notes. + +```typescript +const changelog = await client.changelog(); +console.log(changelog.releases[0]?.version); +``` + +#### `ingest(request)` +Ingest products into the catalog (for merchants). + +```typescript +await client.ingest({ + source: "my_store", + products: [{ sku: "ABC123", title: "Product", price: 29.99, url: "https://..." }], +}); +``` + +#### `exportProducts(options?)` +Export products in CSV or JSON format. + +```typescript +const csv = await client.exportProducts({ format: "csv", category: "Health" }); +``` + +#### `subscribe(callback, options?, intervalMs?)` +Subscribe to new deals with a callback. Returns a stop function. + +```typescript +const { stop } = await client.subscribe( + (deal) => console.log(deal.name), + { min_discount_pct: 50 }, + 60000 // poll every 60s +); +``` + +#### `health()` +Check API health status. + +```typescript +const status = await client.health(); +``` + +#### `apiInfo()` +Get API version and endpoint info. + +```typescript +const info = await client.apiInfo(); +``` + +### Error Handling + +```typescript +import { + BuyWhereClient, + AuthenticationError, + RateLimitError, + NotFoundError, + ValidationError, + ServerError, +} from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +try { + const product = await client.getProduct(999999); +} catch (error) { + if (error instanceof NotFoundError) { + console.log("Product not found"); + } else if (error instanceof RateLimitError) { + console.log("Rate limited, retry later"); + } else if (error instanceof AuthenticationError) { + console.log("Invalid API key"); + } else if (error instanceof ValidationError) { + console.log("Request validation failed"); + } else if (error instanceof ServerError) { + console.log("BuyWhere is unavailable, retry later"); + } +} +``` + +## Common Patterns + +### Run from a Node script + +```bash +node --env-file=.env --input-type=module <<'EOF' +import { BuyWhereClient } from "@buywhere/sdk"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY); +const results = await client.search({ q: "mechanical keyboard", limit: 3 }); + +for (const item of results.items) { + console.log(`${item.name} -> ${item.currency} ${item.price}`); +} +EOF +``` + +### Use in a Next.js route handler + +```typescript +import { BuyWhereClient } from "@buywhere/sdk"; +import { NextResponse } from "next/server"; + +const client = new BuyWhereClient(process.env.BUYWHERE_API_KEY!); + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const q = searchParams.get("q") ?? ""; + + const results = await client.search({ q, limit: 10 }); + return NextResponse.json(results); +} +``` + +## License + +MIT diff --git a/sdk/npm/examples/compare-product.ts b/sdk/npm/examples/compare-product.ts new file mode 100644 index 000000000..aa842cb7c --- /dev/null +++ b/sdk/npm/examples/compare-product.ts @@ -0,0 +1,30 @@ +import { BuyWhereClient } from "../src/index.js"; + +const apiKey = process.env.BUYWHERE_API_KEY; + +if (!apiKey) { + throw new Error("Set BUYWHERE_API_KEY before running this example."); +} + +const query = process.argv[2] ?? "Nintendo Switch OLED"; +const client = new BuyWhereClient(apiKey); + +const search = await client.search({ q: query, limit: 1 }); +const product = search.items[0]; + +if (!product) { + throw new Error(`No product found for "${query}"`); +} + +const comparison = await client.compare({ product_id: product.id }); + +console.log(`Comparing ${comparison.source_product_name}`); +for (const match of comparison.matches.slice(0, 5)) { + console.log(`${match.source} | ${match.currency} ${match.price} | score=${match.match_score}`); +} + +if (comparison.highlights?.cheapest) { + console.log( + `Cheapest: ${comparison.highlights.cheapest.source} ${comparison.highlights.cheapest.currency} ${comparison.highlights.cheapest.price}` + ); +} diff --git a/sdk/npm/examples/get-product.ts b/sdk/npm/examples/get-product.ts new file mode 100644 index 000000000..96dadc0af --- /dev/null +++ b/sdk/npm/examples/get-product.ts @@ -0,0 +1,26 @@ +import { BuyWhereClient } from "../src/index.js"; + +const apiKey = process.env.BUYWHERE_API_KEY; + +if (!apiKey) { + throw new Error("Set BUYWHERE_API_KEY before running this example."); +} + +const productId = Number(process.argv[2] ?? "78234"); + +if (Number.isNaN(productId)) { + throw new Error("Pass a numeric product ID."); +} + +const client = new BuyWhereClient(apiKey); +const product = await client.getProduct(productId); + +console.log(JSON.stringify({ + id: product.id, + name: product.name, + price: product.price, + currency: product.currency, + source: product.source, + buy_url: product.buy_url, + affiliate_url: product.affiliate_url, +}, null, 2)); diff --git a/sdk/npm/examples/price-history.ts b/sdk/npm/examples/price-history.ts new file mode 100644 index 000000000..eca45fe97 --- /dev/null +++ b/sdk/npm/examples/price-history.ts @@ -0,0 +1,34 @@ +import { BuyWhereClient } from "../src/index.js"; + +const apiKey = process.env.BUYWHERE_API_KEY; + +if (!apiKey) { + throw new Error("Set BUYWHERE_API_KEY before running this example."); +} + +const baseUrl = (process.env.BUYWHERE_BASE_URL ?? "https://api.buywhere.ai").replace(/\/$/, ""); +const query = process.argv[2] ?? "iPhone 15"; +const limit = Number(process.argv[3] ?? "30"); +const client = new BuyWhereClient(apiKey, { baseUrl }); + +const search = await client.search({ q: query, limit: 1 }); +const product = search.items[0]; + +if (!product) { + throw new Error(`No product found for "${query}"`); +} + +// Raw HTTP fallback until the TypeScript SDK exposes a dedicated getPriceHistory helper. +const response = await fetch(`${baseUrl}/v1/products/${product.id}/price-history?limit=${limit}`, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, +}); + +if (!response.ok) { + throw new Error(`BuyWhere error ${response.status}: ${await response.text()}`); +} + +const history = await response.json(); +console.log(JSON.stringify(history, null, 2)); diff --git a/sdk/npm/examples/register-webhook.ts b/sdk/npm/examples/register-webhook.ts new file mode 100644 index 000000000..b59214c93 --- /dev/null +++ b/sdk/npm/examples/register-webhook.ts @@ -0,0 +1,34 @@ +const apiKey = process.env.BUYWHERE_API_KEY; + +if (!apiKey) { + throw new Error("Set BUYWHERE_API_KEY before running this example."); +} + +const baseUrl = (process.env.BUYWHERE_BASE_URL ?? "https://api.buywhere.ai").replace(/\/$/, ""); +const callbackUrl = process.env.BUYWHERE_WEBHOOK_URL; + +if (!callbackUrl) { + throw new Error("Set BUYWHERE_WEBHOOK_URL before running this example."); +} + +const response = await fetch(`${baseUrl}/v1/webhooks`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + url: callbackUrl, + event_types: ["price.updated", "price.dropped"], + product_ids: [101, 202], + threshold_percent: 5, + }), +}); + +if (!response.ok) { + throw new Error(`BuyWhere error ${response.status}: ${await response.text()}`); +} + +const webhook = await response.json(); +console.log(JSON.stringify(webhook, null, 2)); diff --git a/sdk/npm/examples/search-products.ts b/sdk/npm/examples/search-products.ts new file mode 100644 index 000000000..a3c799ac9 --- /dev/null +++ b/sdk/npm/examples/search-products.ts @@ -0,0 +1,21 @@ +import { BuyWhereClient } from "../src/index.js"; + +const apiKey = process.env.BUYWHERE_API_KEY; + +if (!apiKey) { + throw new Error("Set BUYWHERE_API_KEY before running this example."); +} + +const query = process.argv[2] ?? "wireless headphones"; +const client = new BuyWhereClient(apiKey); + +const results = await client.search({ + q: query, + limit: 5, + in_stock: true, +}); + +console.log(`Found ${results.total} products for "${query}"`); +for (const item of results.items) { + console.log(`${item.id} | ${item.name} | ${item.currency} ${item.price} | ${item.source}`); +} diff --git a/sdk/npm/package-lock.json b/sdk/npm/package-lock.json new file mode 100644 index 000000000..95ad4397f --- /dev/null +++ b/sdk/npm/package-lock.json @@ -0,0 +1,1538 @@ +{ + "name": "@buywhere/sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@buywhere/sdk", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^25.5.2", + "tsup": "^8.5.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdk/npm/package.json b/sdk/npm/package.json new file mode 100644 index 000000000..7f77bf1c7 --- /dev/null +++ b/sdk/npm/package.json @@ -0,0 +1,56 @@ +{ + "name": "@buywhere/sdk", + "version": "1.0.0", + "description": "Official TypeScript SDK for BuyWhere API — Agent-native product catalog API for AI agent commerce in Singapore and globally", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "prepublishOnly": "npm run build", + "typecheck": "tsc --noEmit", + "example:search": "tsx examples/search-products.ts", + "example:get-product": "tsx examples/get-product.ts", + "example:compare": "tsx examples/compare-product.ts", + "example:price-history": "tsx examples/price-history.ts", + "example:webhook": "tsx examples/register-webhook.ts" + }, + "keywords": [ + "buywhere", + "typescript", + "javascript", + "node", + "nextjs", + "ecommerce", + "singapore", + "product-catalog", + "api", + "ai-agents", + "shopping", + "sdk" + ], + "engines": { + "node": ">=18.0.0" + }, + "license": "MIT", + "devDependencies": { + "@types/node": "^25.5.2", + "tsx": "^4.20.6", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "repository": { + "type": "git", + "url": "https://github.com/buywhere/buywhere-api" + } +} diff --git a/sdk/npm/src/client.ts b/sdk/npm/src/client.ts new file mode 100644 index 000000000..b7167fdb5 --- /dev/null +++ b/sdk/npm/src/client.ts @@ -0,0 +1,542 @@ +import { + Product, + ProductListResponse, + ProductResponse, + CompareResponse, + CompareSearchResponse, + CompareMatrixResponse, + CompareDiffResponse, + TrendingResponse, + CategoryResponse, + DealsResponse, + IngestRequest, + IngestResponse, + ChangelogResponse, + HealthStatus, + ApiInfo, + RateLimitInfo, + SearchOptions, + CompareOptions, + CompareSearchOptions, + CompareDiffOptions, + CompareMatrixOptions, + DealsOptions, + ExportOptions, + AffiliateResolution, + DealItem, + SubscribeCallback, + SubscribeOptions, +} from "./types.js"; +import { + BuyWhereError, + AuthenticationError, + RateLimitError, + NotFoundError, + ValidationError, + ServerError, +} from "./errors.js"; + +const DEFAULT_BASE_URL = "https://api.buywhere.ai"; +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 1000; + +interface RetryConfig { + maxRetries: number; + retryDelay: number; + backoffMultiplier: number; +} + +export class BuyWhereClient { + private baseUrl: string; + private apiKey: string; + private timeout: number; + private maxRetries: number; + private retryDelay: number; + private backoffMultiplier: number; + + constructor( + apiKey: string, + options: { + baseUrl?: string; + timeout?: number; + maxRetries?: number; + retryDelay?: number; + backoffMultiplier?: number; + } = {} + ) { + if (!apiKey) { + throw new Error("API key is required"); + } + + this.apiKey = apiKey; + this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); + this.timeout = options.timeout || DEFAULT_TIMEOUT; + this.maxRetries = options.maxRetries || DEFAULT_MAX_RETRIES; + this.retryDelay = options.retryDelay || DEFAULT_RETRY_DELAY; + this.backoffMultiplier = options.backoffMultiplier || 2; + } + + private getHeaders(): Record { + return { + Authorization: `Bearer ${this.apiKey}`, + "User-Agent": "@buywhere/sdk/1.0.0", + Accept: "application/json", + "Content-Type": "application/json", + }; + } + + private async sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async fetchWithRetry( + url: string, + options: RequestInit = {}, + retryConfig?: Partial + ): Promise<{ data: T; rateLimit: RateLimitInfo }> { + const maxRetries = retryConfig?.maxRetries ?? this.maxRetries; + const retryDelay = retryConfig?.retryDelay ?? this.retryDelay; + const backoffMultiplier = retryConfig?.backoffMultiplier ?? 2; + + let lastError: Error | null = null; + let currentDelay = retryDelay; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(url, { + ...options, + headers: { + ...this.getHeaders(), + ...options.headers, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const rateLimit: RateLimitInfo = { + limit: parseInt(response.headers.get("X-RateLimit-Limit") || "1000"), + remaining: parseInt(response.headers.get("X-RateLimit-Remaining") || "999"), + reset: parseInt(response.headers.get("X-RateLimit-Reset") || "0"), + }; + + if (response.status === 401 || response.status === 403) { + const detail = await response.text(); + throw new AuthenticationError( + `Authentication failed: ${detail}`, + response.status + ); + } + + if (response.status === 404) { + const detail = await response.text(); + throw new NotFoundError(`Resource not found: ${detail}`, response.status); + } + + if (response.status === 429) { + const detail = await response.text(); + throw new RateLimitError( + `Rate limit exceeded: ${detail}`, + response.status + ); + } + + if (response.status === 422) { + const detail = await response.text(); + throw new ValidationError(`Validation error: ${detail}`, response.status); + } + + if (response.status >= 500) { + const detail = await response.text(); + throw new ServerError(`Server error: ${detail}`, response.status); + } + + if (response.status >= 400) { + const detail = await response.text(); + throw new BuyWhereError(`Unexpected error: ${detail}`, response.status); + } + + const data = await response.json(); + return { data: data as T, rateLimit }; + } catch (error) { + lastError = error as Error; + + if (error instanceof AuthenticationError) { + throw error; + } + + if (attempt < maxRetries) { + await this.sleep(currentDelay); + currentDelay *= backoffMultiplier; + } + } + } + + throw lastError || new BuyWhereError("Request failed after retries"); + } + + private async fetchResponseWithRetry( + url: string, + options: RequestInit = {}, + retryConfig?: Partial + ): Promise { + const maxRetries = retryConfig?.maxRetries ?? this.maxRetries; + const retryDelay = retryConfig?.retryDelay ?? this.retryDelay; + const backoffMultiplier = retryConfig?.backoffMultiplier ?? 2; + + let lastError: Error | null = null; + let currentDelay = retryDelay; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(url, { + ...options, + headers: { + ...this.getHeaders(), + ...options.headers, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.status === 401 || response.status === 403) { + const detail = await response.text(); + throw new AuthenticationError( + `Authentication failed: ${detail}`, + response.status + ); + } + + if (response.status === 404) { + const detail = await response.text(); + throw new NotFoundError(`Resource not found: ${detail}`, response.status); + } + + if (response.status === 429) { + const detail = await response.text(); + throw new RateLimitError( + `Rate limit exceeded: ${detail}`, + response.status + ); + } + + if (response.status === 422) { + const detail = await response.text(); + throw new ValidationError(`Validation error: ${detail}`, response.status); + } + + if (response.status >= 500) { + const detail = await response.text(); + throw new ServerError(`Server error: ${detail}`, response.status); + } + + if (response.status >= 400) { + const detail = await response.text(); + throw new BuyWhereError(`Unexpected error: ${detail}`, response.status); + } + + return response; + } catch (error) { + lastError = error as Error; + + if (error instanceof AuthenticationError) { + throw error; + } + + if (attempt < maxRetries) { + await this.sleep(currentDelay); + currentDelay *= backoffMultiplier; + } + } + } + + throw lastError || new BuyWhereError("Request failed after retries"); + } + + async health(): Promise { + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/health` + ); + return data; + } + + async apiInfo(): Promise { + const { data } = await this.fetchWithRetry(`${this.baseUrl}/v1`); + return data; + } + + async changelog(): Promise { + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/changelog` + ); + return data; + } + + async search(options: SearchOptions = {}): Promise { + const params = new URLSearchParams(); + if (options.q) params.set("q", options.q); + if (options.category) params.set("category", options.category); + if (options.min_price !== undefined) + params.set("min_price", options.min_price.toString()); + if (options.max_price !== undefined) + params.set("max_price", options.max_price.toString()); + if (options.source) params.set("source", options.source); + if (options.in_stock !== undefined) + params.set("in_stock", options.in_stock.toString()); + if (options.limit !== undefined) + params.set("limit", options.limit.toString()); + if (options.offset !== undefined) + params.set("offset", options.offset.toString()); + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/search?${params.toString()}` + ); + return data; + } + + async getProduct(productId: number): Promise { + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/products/${productId}` + ); + return data; + } + + async resolveAffiliate( + productId: number, + options: { trackClick?: boolean } = {} + ): Promise { + const product = await this.getProduct(productId); + let resolvedUrl = product.affiliate_url || product.buy_url; + + if (options.trackClick) { + const response = await this.fetchResponseWithRetry( + `${this.baseUrl}/v1/products/${productId}/click`, + { + method: "POST", + redirect: "manual", + }, + { maxRetries: 1 } + ); + resolvedUrl = response.headers.get("location") || resolvedUrl; + } + + return { + product_id: product.id, + buy_url: product.buy_url, + affiliate_url: product.affiliate_url, + resolved_url: resolvedUrl, + tracked_click: options.trackClick ?? false, + }; + } + + async compare(options: CompareOptions): Promise { + const params = new URLSearchParams({ + product_id: options.product_id.toString(), + }); + if (options.min_price !== undefined) + params.set("min_price", options.min_price.toString()); + if (options.max_price !== undefined) + params.set("max_price", options.max_price.toString()); + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/products/compare?${params.toString()}` + ); + return data; + } + + async compareSearch( + options: CompareSearchOptions + ): Promise { + const params = new URLSearchParams({ q: options.q }); + if (options.limit !== undefined) { + params.set("limit", options.limit.toString()); + } + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/products/compare?${params.toString()}` + ); + return data; + } + + async compareProductById( + productId: number, + options: { min_price?: number; max_price?: number } = {} + ): Promise { + const params = new URLSearchParams(); + if (options.min_price !== undefined) + params.set("min_price", options.min_price.toString()); + if (options.max_price !== undefined) + params.set("max_price", options.max_price.toString()); + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/compare/${productId}${params.toString() ? `?${params.toString()}` : ""}` + ); + return data; + } + + async compareProducts( + options: CompareMatrixOptions + ): Promise { + const body = { + product_ids: options.product_ids, + min_price: options.min_price, + max_price: options.max_price, + }; + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/products/compare`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + return data; + } + + async compareProductsDiff( + options: CompareDiffOptions + ): Promise { + const body = { + product_ids: options.product_ids, + include_image_similarity: options.include_image_similarity ?? false, + }; + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/products/compare/diff`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + return data; + } + + async getDeals(options: DealsOptions = {}): Promise { + const params = new URLSearchParams(); + if (options.category) params.set("category", options.category); + if (options.min_discount_pct !== undefined) + params.set("min_discount_pct", options.min_discount_pct.toString()); + if (options.limit !== undefined) + params.set("limit", options.limit.toString()); + if (options.offset !== undefined) + params.set("offset", options.offset.toString()); + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/deals?${params.toString()}` + ); + return data; + } + + async trending(period: '24h' | '7d' = '7d', category?: string, limit: number = 50): Promise { + const params = new URLSearchParams(); + params.set('period', period); + if (category) params.set("category", category); + params.set("limit", limit.toString()); + + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/products/trending?${params.toString()}` + ); + return data; + } + + async categories(): Promise { + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/categories` + ); + return data; + } + + async ingest(request: IngestRequest): Promise { + const { data } = await this.fetchWithRetry( + `${this.baseUrl}/v1/ingest/products`, + { + method: "POST", + body: JSON.stringify(request), + }, + { maxRetries: 1 } + ); + return data; + } + + async exportProducts( + options: ExportOptions = {} + ): Promise { + const params = new URLSearchParams(); + if (options.format) params.set("format", options.format); + if (options.category) params.set("category", options.category); + if (options.source) params.set("source", options.source); + if (options.min_price !== undefined) + params.set("min_price", options.min_price.toString()); + if (options.max_price !== undefined) + params.set("max_price", options.max_price.toString()); + if (options.limit !== undefined) + params.set("limit", options.limit.toString()); + if (options.offset !== undefined) + params.set("offset", options.offset.toString()); + + const url = `${this.baseUrl}/v1/products/export?${params.toString()}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(url, { + headers: this.getHeaders(), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (options.format === "csv") { + return await response.text(); + } + + return (await response.json()) as Product[]; + } + + async subscribe( + callback: SubscribeCallback, + options: SubscribeOptions = {}, + intervalMs: number = 60000 + ): Promise<{ stop: () => void }> { + let stopped = false; + + const run = async () => { + while (!stopped) { + try { + const deals = await this.getDeals({ + category: options.category, + min_discount_pct: options.min_discount_pct, + limit: 50, + }); + for (const deal of deals.items) { + if (stopped) break; + await callback(deal); + } + } catch (e) { + // Continue polling even on error + } + if (!stopped) { + await this.sleep(intervalMs); + } + } + }; + + run(); + + return { + stop: () => { + stopped = true; + }, + }; + } +} + +export default BuyWhereClient; diff --git a/sdk/npm/src/errors.ts b/sdk/npm/src/errors.ts new file mode 100644 index 000000000..0ab448a6f --- /dev/null +++ b/sdk/npm/src/errors.ts @@ -0,0 +1,44 @@ +export class BuyWhereError extends Error { + constructor( + message: string, + public statusCode?: number + ) { + super(message); + this.name = "BuyWhereError"; + } +} + +export class AuthenticationError extends BuyWhereError { + constructor(message: string, statusCode?: number) { + super(message, statusCode); + this.name = "AuthenticationError"; + } +} + +export class RateLimitError extends BuyWhereError { + constructor(message: string, statusCode?: number) { + super(message, statusCode); + this.name = "RateLimitError"; + } +} + +export class NotFoundError extends BuyWhereError { + constructor(message: string, statusCode?: number) { + super(message, statusCode); + this.name = "NotFoundError"; + } +} + +export class ValidationError extends BuyWhereError { + constructor(message: string, statusCode?: number) { + super(message, statusCode); + this.name = "ValidationError"; + } +} + +export class ServerError extends BuyWhereError { + constructor(message: string, statusCode?: number) { + super(message, statusCode); + this.name = "ServerError"; + } +} \ No newline at end of file diff --git a/sdk/npm/src/index.ts b/sdk/npm/src/index.ts new file mode 100644 index 000000000..3728c5d33 --- /dev/null +++ b/sdk/npm/src/index.ts @@ -0,0 +1,10 @@ +export { BuyWhereClient } from "./client.js"; +export { + BuyWhereError, + AuthenticationError, + RateLimitError, + NotFoundError, + ValidationError, + ServerError, +} from "./errors.js"; +export * from "./types.js"; \ No newline at end of file diff --git a/sdk/npm/src/types.ts b/sdk/npm/src/types.ts new file mode 100644 index 000000000..2946dce91 --- /dev/null +++ b/sdk/npm/src/types.ts @@ -0,0 +1,326 @@ +export interface Product { + id: number; + sku: string; + source: string; + merchant_id: string; + name: string; + description: string; + price: number; + currency: string; + buy_url: string; + affiliate_url: string | null; + image_url: string | null; + category: string; + category_path: string[]; + is_available: boolean; + metadata: Record; + updated_at: string; + match_score?: number; + price_rank?: number; + original_price?: number; + discount_pct?: number; + savings_vs_most_expensive?: number; + savings_pct?: number; +} + +export interface ProductListResponse { + total: number; + limit: number; + offset: number; + has_more: boolean; + items: Product[]; +} + +export interface ProductResponse { + id: number; + sku: string; + source: string; + merchant_id: string; + name: string; + description: string; + price: number; + currency: string; + buy_url: string; + affiliate_url: string | null; + image_url: string | null; + category: string; + category_path: string[]; + is_available: boolean; + metadata: Record; + updated_at: string; +} + +export interface CompareMatch { + id: number; + sku: string; + source: string; + merchant_id: string; + name: string; + description: string | null; + price: number; + currency: string; + buy_url: string; + affiliate_url: string | null; + image_url: string | null; + brand: string | null; + category: string | null; + category_path: string[] | null; + rating: number | null; + is_available: boolean; + last_checked: string | null; + metadata: Record | null; + updated_at: string; + match_score: number; + savings_vs_most_expensive?: number; + savings_pct?: number; +} + +export interface CompareHighlights { + cheapest: CompareMatch | null; + best_rated: CompareMatch | null; + fastest_shipping: CompareMatch | null; +} + +export interface CompareResponse { + source_product_id: number; + source_product_name: string; + total_matches: number; + matches: CompareMatch[]; + highlights: CompareHighlights | null; +} + +export interface CompareSearchMatch { + id: number; + sku: string; + source: string; + merchant_id: string; + name: string; + description: string | null; + price: number; + currency: string; + buy_url: string; + affiliate_url: string | null; + image_url: string | null; + brand: string | null; + category: string | null; + category_path: string[] | null; + rating: number | null; + is_available: boolean; + in_stock?: boolean | null; + stock_level?: string | null; + last_checked: string | null; + metadata: Record | null; + updated_at: string | null; + match_score: number; +} + +export interface CompareSearchResponse { + query: string; + items: CompareSearchMatch[]; + total: number; + cheapest_product_id: number | null; + best_rated_product_id: number | null; + fastest_shipping_product_id: number | null; +} + +export interface CompareMatrixResponse { + total_products: number; + comparisons: CompareResponse[]; +} + +export interface FieldDiff { + field: string; + values: unknown[]; + all_identical: boolean; +} + +export interface CompareDiffResponse { + products: Product[]; + field_diffs: FieldDiff[]; + identical_fields: string[]; + cheapest_product_id: number; + most_expensive_product_id: number; + price_spread: number; + price_spread_pct: number; +} + +export interface TrendingResponse { + data: Product[]; + meta: { + period: string; + period_hours: number; + country_code: string; + top_searches: Array<{ + query: string; + category: string | null; + search_count: number; + }>; + limit: number; + }; +} + +export interface Category { + name: string; + count: number; + children: Category[]; +} + +export interface CategoryResponse { + categories: Category[]; + total: number; +} + +export interface DealItem { + id: number; + name: string; + price: number; + original_price: number; + discount_pct: number; + currency: string; + source: string; + category: string; + buy_url: string; + affiliate_url: string | null; + image_url: string | null; + metadata: Record; +} + +export interface DealsResponse { + total: number; + limit: number; + offset: number; + items: DealItem[]; +} + +export interface IngestProduct { + sku: string; + merchant_id?: string; + title: string; + description?: string; + price: number; + currency?: string; + url: string; + image_url?: string; + category?: string; + category_path?: string[]; + brand?: string; + is_active?: boolean; + metadata?: Record; +} + +export interface IngestRequest { + source: string; + products: IngestProduct[]; +} + +export interface IngestResponse { + ingested: number; + updated: number; + failed: number; + errors: string[]; +} + +export interface ChangelogRelease { + version: string; + date: string; + changes: { + category: string; + group: string | null; + description: string; + }[]; +} + +export interface ChangelogResponse { + api_version: string; + releases: ChangelogRelease[]; +} + +export interface HealthStatus { + status: string; + version: string; + environment: string; +} + +export interface ApiInfo { + api: string; + version: string; + endpoints: Record; + auth: string; + docs: string; +} + +export interface AffiliateResolution { + product_id: number; + buy_url: string; + affiliate_url: string | null; + resolved_url: string; + tracked_click: boolean; +} + +export interface RateLimitInfo { + limit: number; + remaining: number; + reset: number; +} + +export interface SearchOptions { + q?: string; + category?: string; + min_price?: number; + max_price?: number; + source?: string; + in_stock?: boolean; + limit?: number; + offset?: number; +} + +export interface CompareOptions { + product_id: number; + min_price?: number; + max_price?: number; +} + +export interface CompareSearchOptions { + q: string; + limit?: number; +} + +export interface CompareByIdOptions { + min_price?: number; + max_price?: number; +} + +export interface CompareDiffOptions { + product_ids: number[]; + include_image_similarity?: boolean; +} + +export interface CompareMatrixOptions { + product_ids: number[]; + min_price?: number; + max_price?: number; +} + +export interface DealsOptions { + category?: string; + min_discount_pct?: number; + limit?: number; + offset?: number; +} + +export interface ExportOptions { + format?: "csv" | "json"; + category?: string; + source?: string; + min_price?: number; + max_price?: number; + limit?: number; + offset?: number; +} + +export type SubscribeCallback = (deal: DealItem) => void | Promise; + +export interface SubscribeOptions { + category?: string; + min_discount_pct?: number; +} diff --git a/sdk/npm/tsconfig.json b/sdk/npm/tsconfig.json new file mode 100644 index 000000000..6a304398a --- /dev/null +++ b/sdk/npm/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "types": ["node"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/src/routes/productRoutes.js b/src/routes/productRoutes.js new file mode 100644 index 000000000..c1e28a88a --- /dev/null +++ b/src/routes/productRoutes.js @@ -0,0 +1,475 @@ +const express = require('express'); +const router = express.Router(); +const { Product, SearchQuery } = require('../models'); +const mongoose = require('mongoose'); +const { logSearchError } = require('../utils/searchQueryLogger'); + +const QUERY_TIMEOUT_MS = 5000; +const CACHE_MAX_AGE = 'public, max-age=60, stale-while-revalidate=300'; + +const PRODUCT_PROJECTION = { + sku: 1, + source: 1, + merchant_id: 1, + title: 1, + price: 1, + currency: 1, + price_sgd: 1, + region: 1, + country_code: 1, + url: 1, + brand: 1, + category: 1, + category_path: 1, + image_url: 1, + is_available: 1, + in_stock: 1, + stock_level: 1, + data_updated_at: 1, + rating: 1, + review_count: 1, + avg_rating: 1 +}; + +function setCacheHeaders(res) { + res.set('Cache-Control', CACHE_MAX_AGE); +} + +async function searchProducts(req, res) { + const startTime = Date.now(); + const requestId = req.headers['x-request-id'] || null; + const ip = req.ip || req.headers['x-forwarded-for'] || null; + const userAgent = req.headers['user-agent'] || null; + const { q, country_code = 'US', limit = 20, offset = 0 } = req.query; + + if (!q || q.trim().length === 0) { + return res.status(400).json({ error: 'Search query "q" is required' }); + } + + try { + const country = country_code.toUpperCase(); + const filter = { + is_active: true, + country_code: country, + $text: { $search: q } + }; + + const [products, total] = await Promise.all([ + Product.find(filter, { score: { $meta: 'textScore' } }) + .sort({ score: { $meta: 'textScore' } }) + .skip(parseInt(offset)) + .limit(Math.min(parseInt(limit), 100)) + .maxTimeMS(QUERY_TIMEOUT_MS) + .lean() + .select(PRODUCT_PROJECTION), + Product.countDocuments(filter).maxTimeMS(QUERY_TIMEOUT_MS) + ]); + + setCacheHeaders(res); + const limitInt = parseInt(limit); + const offsetInt = parseInt(offset); + + SearchQuery.create({ + query: q.trim().toLowerCase(), + country_code: country, + search_date: new Date() + }).catch(err => console.error('Failed to log search query:', err)); + + return res.json({ + data: products, + meta: { + total, + limit: limitInt, + offset: offsetInt, + next_offset: offsetInt + limitInt < total ? offsetInt + limitInt : null + } + }); + } catch (error) { + const durationMs = Date.now() - startTime; + logSearchError(null, { + error, + query: q, + countryCode: country_code, + durationMs, + requestId, + ip, + userAgent + }); + return res.status(500).json({ error: 'Search failed' }); + } +} + +async function getProduct(req, res) { + try { + const { id } = req.params; + const { country_code = 'US' } = req.query; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: 'Invalid product ID format' }); + } + + const product = await Product.findOne({ + _id: id, + is_active: true, + country_code: country_code.toUpperCase() + }) + .maxTimeMS(QUERY_TIMEOUT_MS) + .lean() + .select(PRODUCT_PROJECTION); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + setCacheHeaders(res); + return res.json({ data: product }); + } catch (error) { + console.error('Get product error:', error); + return res.status(500).json({ error: 'Failed to get product' }); + } +} + +async function getDeals(req, res) { + try { + const { country_code = 'US', limit = 20, offset = 0, min_price, max_price } = req.query; + + const country = country_code.toUpperCase(); + const filter = { + is_active: true, + country_code: country, + is_available: true, + in_stock: true + }; + + if (min_price !== undefined || max_price !== undefined) { + filter.price = {}; + if (min_price !== undefined) filter.price.$gte = parseFloat(min_price); + if (max_price !== undefined) filter.price.$lte = parseFloat(max_price); + } + + const [products, total] = await Promise.all([ + Product.find(filter) + .sort({ data_updated_at: -1 }) + .skip(parseInt(offset)) + .limit(Math.min(parseInt(limit), 100)) + .maxTimeMS(QUERY_TIMEOUT_MS) + .lean() + .select(PRODUCT_PROJECTION), + Product.countDocuments(filter).maxTimeMS(QUERY_TIMEOUT_MS) + ]); + + setCacheHeaders(res); + const limitInt = parseInt(limit); + const offsetInt = parseInt(offset); + return res.json({ + data: products, + meta: { + total, + limit: limitInt, + offset: offsetInt, + next_offset: offsetInt + limitInt < total ? offsetInt + limitInt : null + } + }); + } catch (error) { + console.error('Deals error:', error); + return res.status(500).json({ error: 'Failed to get deals' }); + } +} + +async function listProducts(req, res) { + try { + const { + category_id, + price_min, + price_max, + currency, + region, + limit = 20, + offset = 0, + sort_by = 'relevance', + country_code = 'US' + } = req.query; + + // Build filter + const filter = { + is_active: true, + country_code: country_code.toUpperCase() + }; + + // Add category filter + if (category_id) { + filter.category = category_id; + } + + // Add region filter + if (region) { + filter.region = region.toLowerCase(); + } + + // Add currency filter + if (currency) { + filter.currency = currency.toUpperCase(); + } + + // Add price range filter + if (price_min !== undefined || price_max !== undefined) { + filter.price = {}; + if (price_min !== undefined) filter.price.$gte = parseFloat(price_min); + if (price_max !== undefined) filter.price.$lte = parseFloat(price_max); + } + + // Build sort options + let sortOptions = {}; + switch (sort_by) { + case 'price_asc': + sortOptions = { price: 1 }; + break; + case 'price_desc': + sortOptions = { price: -1 }; + break; + case 'relevance': + default: + sortOptions = { data_updated_at: -1 }; // Most recently updated first + break; + } + + // Parse limit and offset + const limitInt = Math.min(parseInt(limit), 100); // Max 100 results + const offsetInt = parseInt(offset); + + // Get products and total count + const [products, total] = await Promise.all([ + Product.find(filter) + .sort(sortOptions) + .skip(offsetInt) + .limit(limitInt) + .maxTimeMS(QUERY_TIMEOUT_MS) + .lean() + .select(PRODUCT_PROJECTION), + Product.countDocuments(filter).maxTimeMS(QUERY_TIMEOUT_MS) + ]); + + setCacheHeaders(res); + return res.json({ + data: products, + meta: { + total, + next_offset: offsetInt + limitInt < total ? offsetInt + limitInt : null + } + }); + } catch (error) { + console.error('List products error:', error); + return res.status(500).json({ error: 'Failed to list products' }); + } +} + +async function compareProduct(req, res) { + try { + const { _id, country_code = 'US' } = req.query; + + if (!_id) { + return res.status(400).json({ error: '_id query param is required' }); + } + + if (!mongoose.Types.ObjectId.isValid(_id)) { + return res.status(400).json({ error: 'Invalid _id format' }); + } + + const country = country_code.toUpperCase(); + + const product = await Product.findOne({ + _id, + is_active: true, + country_code: country + }) + .maxTimeMS(QUERY_TIMEOUT_MS) + .lean() + .select(PRODUCT_PROJECTION); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + const similarProducts = await Product.find({ + _id: { $ne: product._id }, + is_active: true, + country_code: country, + category: product.category, + price: { + $gte: product.price * 0.8, + $lte: product.price * 1.2 + } + }) + .hint({ is_active: 1, country_code: 1, category: 1, price: 1 }) + .limit(5) + .maxTimeMS(QUERY_TIMEOUT_MS) + .lean() + .select(PRODUCT_PROJECTION); + + setCacheHeaders(res); + return res.json({ + data: { + product, + similar: similarProducts + } + }); + } catch (error) { + console.error('Compare error:', error); + return res.status(500).json({ error: 'Failed to compare products' }); + } +} + +async function getTrending(req, res) { + try { + const { period = '7d', limit = 50, country_code = 'SG', category } = req.query; + + let hoursBack; + if (period === '24h') { + hoursBack = 24; + } else if (period === '7d') { + hoursBack = 168; + } else { + return res.status(400).json({ error: 'Invalid period. Use 24h or 7d' }); + } + + const dateCutoff = new Date(); + dateCutoff.setHours(dateCutoff.getHours() - hoursBack); + + const matchStage = { + search_timestamp: { $gte: dateCutoff }, + country_code: country_code.toUpperCase() + }; + + if (category) { + matchStage.category = category; + } + + const topQueries = await SearchQuery.aggregate([ + { $match: matchStage }, + { + $group: { + _id: { query: '$query', category: '$category' }, + search_count: { $sum: 1 } + } + }, + { $sort: { search_count: -1 } }, + { $limit: Math.min(parseInt(limit), 100) } + ]); + + const queries = topQueries.map(q => q._id.query); + + const products = await Product.find({ + is_active: true, + country_code: country_code.toUpperCase(), + $text: { $search: queries.join(' ') } + }) + .limit(100) + .maxTimeMS(QUERY_TIMEOUT_MS) + .lean() + .select(PRODUCT_PROJECTION); + + setCacheHeaders(res); + return res.json({ + data: products, + meta: { + period, + period_hours: hoursBack, + country_code: country_code.toUpperCase(), + top_searches: topQueries.map(q => ({ + query: q._id.query, + category: q._id.category, + search_count: q.search_count + })), + limit: parseInt(limit) + } + }); + } catch (error) { + console.error('Trending error:', error); + return res.status(500).json({ error: 'Failed to get trending products' }); + } +} + +async function autocomplete(req, res) { + const startTime = Date.now(); + const { q, limit = 5, country_code = 'SG' } = req.query; + + if (!q || q.trim().length === 0) { + return res.json({ suggestions: [] }); + } + + const limitInt = Math.min(parseInt(limit), 20); + const country = country_code.toUpperCase(); + const queryStr = q.trim(); + + const cacheKey = `autocomplete:${country}:${queryStr}:${limitInt}`; + const cached = cache.get(cacheKey); + if (cached) { + res.set('X-Cache', 'HIT'); + return res.json({ suggestions: cached }); + } + + try { + let suggestions = []; + + if (process.env.TYPESENSE_URL) { + try { + const tsRes = await fetch( + `${process.env.TYPESENSE_URL}/collections/products/documents/search?q=${encodeURIComponent(queryStr)}&query_by=title&prefix_algo=prefix_search&limit=${limitInt}&filter_by=country_code:${country}&query_by_weights=title:3,category:1`, + { + headers: { + 'X-TYPESENSE-API-KEY': process.env.TYPESENSE_API_KEY || '' + }, + signal: AbortSignal.timeout(1000) + } + ); + if (tsRes.ok) { + const tsData = await tsRes.json(); + suggestions = (tsData.hits || []).map(h => h.document.title); + } + } catch (tsErr) { + console.warn('Typesense autocomplete failed, falling back to DB:', tsErr.message); + } + } + + if (suggestions.length === 0) { + const words = queryStr.split(/\s+/); + const orConditions = words.map(w => ({ + title: { $regex: `^${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, $options: 'i' } + })); + + const products = await Product.find({ + is_active: true, + country_code: country, + $or: orConditions.length === 1 ? orConditions : [ + { $or: orConditions }, + { title: { $regex: `^${queryStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, $options: 'i' } } + ] + }) + .sort({ data_updated_at: -1 }) + .limit(limitInt) + .select({ title: 1, _id: 0 }) + .lean(); + + suggestions = products.map(p => p.title); + } + + cache.set(cacheKey, suggestions, 30000); + return res.json({ suggestions }); + } catch (error) { + console.error('Autocomplete error:', error); + return res.status(500).json({ error: 'Autocomplete failed' }); + } +} + +const cache = new Map(); +setInterval(() => cache.clear(), 60000); + +router.get('/search', searchProducts); +router.get('/autocomplete', autocomplete); +router.get('/deals', getDeals); +router.get('/trending', getTrending); +router.get('/compare', compareProduct); +router.get('/', listProducts); // New paginated product listing endpoint +router.get('/:id', getProduct); + +module.exports = router; \ No newline at end of file