forked from cartsnitch/cartsnitch
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aff898545 | |||
| 24f0dd0e67 |
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add GIN index on upc_variants and alter column to JSONB.
|
||||||
|
|
||||||
|
Revision ID: 009_add_gin_index_upc_variants
|
||||||
|
Revises: 008_create_domain_tables
|
||||||
|
Create Date: 2026-04-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "009_add_gin_index_upc_variants"
|
||||||
|
down_revision = "008_create_domain_tables"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"normalized_products",
|
||||||
|
"upc_variants",
|
||||||
|
type_=sa.dialects.postgresql.JSONB(),
|
||||||
|
postgresql_using="upc_variants::jsonb",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_normalized_products_upc_variants_gin",
|
||||||
|
"normalized_products",
|
||||||
|
["upc_variants"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_normalized_products_upc_variants_gin", table_name="normalized_products")
|
||||||
|
op.alter_column(
|
||||||
|
"normalized_products",
|
||||||
|
"upc_variants",
|
||||||
|
type_=sa.JSON(),
|
||||||
|
)
|
||||||
@@ -69,9 +69,7 @@ async def get_current_user(
|
|||||||
token: str | None = None
|
token: str | None = None
|
||||||
|
|
||||||
# 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev)
|
# 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev)
|
||||||
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(
|
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
SESSION_COOKIE_NAME
|
|
||||||
)
|
|
||||||
if cookie_token:
|
if cookie_token:
|
||||||
# Better-Auth cookie format is "token.sessionId" — extract just the token part
|
# Better-Auth cookie format is "token.sessionId" — extract just the token part
|
||||||
token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token
|
token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token
|
||||||
@@ -88,9 +86,7 @@ async def get_current_user(
|
|||||||
detail="Authentication required",
|
detail="Authentication required",
|
||||||
)
|
)
|
||||||
|
|
||||||
user_id = await _validate_session_token(token, db)
|
return await _validate_session_token(token, db)
|
||||||
request.state.user_id = user_id
|
|
||||||
return user_id
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_service_key(x_service_key: str = Header()) -> None:
|
async def verify_service_key(x_service_key: str = Header()) -> None:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from cartsnitch_api.auth.routes import router as auth_router
|
|||||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||||
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
||||||
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
||||||
from cartsnitch_api.middleware.audit import add_audit_middleware
|
|
||||||
from cartsnitch_api.routes.alerts import router as alerts_router
|
from cartsnitch_api.routes.alerts import router as alerts_router
|
||||||
from cartsnitch_api.routes.coupons import router as coupons_router
|
from cartsnitch_api.routes.coupons import router as coupons_router
|
||||||
from cartsnitch_api.routes.health import router as health_router
|
from cartsnitch_api.routes.health import router as health_router
|
||||||
@@ -41,7 +40,6 @@ def create_app() -> FastAPI:
|
|||||||
add_cors_middleware(app)
|
add_cors_middleware(app)
|
||||||
add_error_monitor_middleware(app)
|
add_error_monitor_middleware(app)
|
||||||
add_rate_limit_middleware(app)
|
add_rate_limit_middleware(app)
|
||||||
add_audit_middleware(app)
|
|
||||||
|
|
||||||
# Exception handlers
|
# Exception handlers
|
||||||
add_error_handlers(app)
|
add_error_handlers(app)
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Audit logging middleware for sensitive API operations.
|
|
||||||
|
|
||||||
Logs structured JSON for POST/PUT/PATCH/DELETE requests and GET /auth/me.
|
|
||||||
Never logs request bodies, response bodies, Authorization headers, or cookie values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
|
|
||||||
logger = logging.getLogger("cartsnitch_api.audit")
|
|
||||||
|
|
||||||
HEALTH_PATHS = {"/health", "/healthz", "/ready"}
|
|
||||||
|
|
||||||
|
|
||||||
class AuditMiddleware(BaseHTTPMiddleware):
|
|
||||||
"""Middleware to log structured audit events for sensitive operations."""
|
|
||||||
|
|
||||||
async def dispatch(
|
|
||||||
self,
|
|
||||||
request: Request,
|
|
||||||
call_next: Callable[[Request], Awaitable],
|
|
||||||
):
|
|
||||||
if request.method == "OPTIONS" or request.url.path in HEALTH_PATHS:
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
method = request.method
|
|
||||||
path = request.url.path
|
|
||||||
|
|
||||||
is_sensitive_write = method in {"POST", "PUT", "PATCH", "DELETE"}
|
|
||||||
is_auth_me_read = method == "GET" and path == "/auth/me"
|
|
||||||
|
|
||||||
if not (is_sensitive_write or is_auth_me_read):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
start = time.perf_counter()
|
|
||||||
response = await call_next(request)
|
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
|
||||||
|
|
||||||
user_id = getattr(request.state, "user_id", None)
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
|
|
||||||
log_entry = {
|
|
||||||
"event": "audit",
|
|
||||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
||||||
"user_id": user_id,
|
|
||||||
"method": method,
|
|
||||||
"path": path,
|
|
||||||
"client_ip": client_ip,
|
|
||||||
"status_code": response.status_code,
|
|
||||||
"duration_ms": round(duration_ms, 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(json.dumps(log_entry))
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def add_audit_middleware(app: FastAPI) -> None:
|
|
||||||
app.add_middleware(AuditMiddleware)
|
|
||||||
@@ -4,7 +4,6 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available.
|
|||||||
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
|
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
@@ -72,8 +71,8 @@ def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]:
|
|||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
token = auth_header[7:]
|
token = auth_header[7:]
|
||||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
# Use last 16 chars of token as key to avoid storing full tokens
|
||||||
return f"token:{token_hash}", _auth_limiter
|
return f"token:{token[-16:]}", _auth_limiter
|
||||||
|
|
||||||
# Fallback to IP for unauthenticated non-public endpoints
|
# Fallback to IP for unauthenticated non-public endpoints
|
||||||
return f"ip:{_get_client_ip(request)}", _public_limiter
|
return f"ip:{_get_client_ip(request)}", _public_limiter
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Tests for rate limiting middleware."""
|
"""Tests for rate limiting middleware."""
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter, _get_rate_limit_key
|
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
|
||||||
|
|
||||||
|
|
||||||
class TestSlidingWindowCounter:
|
class TestSlidingWindowCounter:
|
||||||
@@ -55,32 +53,3 @@ async def test_health_skips_rate_limit(client):
|
|||||||
resp = await client.get("/health")
|
resp = await client.get("/health")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "x-ratelimit-limit" not in resp.headers
|
assert "x-ratelimit-limit" not in resp.headers
|
||||||
|
|
||||||
|
|
||||||
class TestGetRateLimitKey:
|
|
||||||
def _make_request(self, auth_header: str = "") -> MagicMock:
|
|
||||||
req = MagicMock()
|
|
||||||
req.url.path = "/purchases"
|
|
||||||
req.headers = {"authorization": auth_header} if auth_header else {}
|
|
||||||
return req
|
|
||||||
|
|
||||||
def test_distinct_tokens_produce_distinct_keys(self):
|
|
||||||
req1 = self._make_request("Bearer token_alpha_12345")
|
|
||||||
req2 = self._make_request("Bearer token_beta_67890")
|
|
||||||
key1, _ = _get_rate_limit_key(req1)
|
|
||||||
key2, _ = _get_rate_limit_key(req2)
|
|
||||||
assert key1 != key2
|
|
||||||
|
|
||||||
def test_same_token_produces_same_key(self):
|
|
||||||
req1 = self._make_request("Bearer same_token_value_abc")
|
|
||||||
req2 = self._make_request("Bearer same_token_value_abc")
|
|
||||||
key1, _ = _get_rate_limit_key(req1)
|
|
||||||
key2, _ = _get_rate_limit_key(req2)
|
|
||||||
assert key1 == key2
|
|
||||||
|
|
||||||
def test_key_does_not_contain_raw_token_suffix(self):
|
|
||||||
raw_token = "my_secret_jwt_token_xyz"
|
|
||||||
req = self._make_request(f"Bearer {raw_token}")
|
|
||||||
key, _ = _get_rate_limit_key(req)
|
|
||||||
assert raw_token[-16:] not in key
|
|
||||||
assert raw_token not in key
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import JSON, String
|
from sqlalchemy import JSON, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from cartsnitch_common.constants import ProductCategory, SizeUnit
|
from cartsnitch_common.constants import ProductCategory, SizeUnit
|
||||||
@@ -26,7 +27,9 @@ class NormalizedProduct(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
brand: Mapped[str | None] = mapped_column(String(200))
|
brand: Mapped[str | None] = mapped_column(String(200))
|
||||||
size: Mapped[str | None] = mapped_column(String(50))
|
size: Mapped[str | None] = mapped_column(String(50))
|
||||||
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
|
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
|
||||||
upc_variants: Mapped[list[str] | None] = mapped_column(JSON, default=list)
|
upc_variants: Mapped[list[str] | None] = mapped_column(
|
||||||
|
JSON().with_variant(JSONB(), "postgresql"), default=list
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
|
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
|
||||||
|
|||||||
Generated
+28
-100
@@ -38,7 +38,7 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^6.4.2",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-pwa": "^0.21.2",
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
@@ -1867,40 +1867,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
|
||||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
|
||||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -2703,25 +2669,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@emnapi/core": "^1.7.1",
|
|
||||||
"@emnapi/runtime": "^1.7.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@noble/ciphers": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||||
@@ -3712,17 +3659,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
|
||||||
"version": "0.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/aria-query": {
|
"node_modules/@types/aria-query": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
@@ -4230,6 +4166,33 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||||
@@ -9510,14 +9473,6 @@
|
|||||||
"typescript": ">=4.8.4"
|
"typescript": ">=4.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -10065,33 +10020,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/spy": "3.2.4",
|
|
||||||
"estree-walker": "^3.0.3",
|
|
||||||
"magic-string": "^0.30.17"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"msw": "^2.4.9",
|
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"msw": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vite": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/w3c-xmlserializer": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^6.4.2",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-pwa": "^0.21.2",
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ Matches products across retailers by:
|
|||||||
2. Fuzzy name matching via token-based Jaccard similarity (lower confidence)
|
2. Fuzzy name matching via token-based Jaccard similarity (lower confidence)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from cartsnitch_common.models.product import NormalizedProduct
|
from cartsnitch_common.models.product import NormalizedProduct
|
||||||
from sqlalchemy import select
|
from sqlalchemy import cast, func, select, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
@@ -96,17 +98,24 @@ def jaccard_similarity(a: str, b: str) -> float:
|
|||||||
def match_by_upc(session: Session, upc: str) -> MatchResult | None:
|
def match_by_upc(session: Session, upc: str) -> MatchResult | None:
|
||||||
"""Find a normalized product by exact UPC match.
|
"""Find a normalized product by exact UPC match.
|
||||||
|
|
||||||
Loads products with upc_variants and checks membership in Python
|
Uses PostgreSQL JSONB containment (@>) for production efficiency.
|
||||||
for cross-database compatibility (works on both PostgreSQL and SQLite).
|
Falls back to LIKE on SQLite for test compatibility.
|
||||||
"""
|
"""
|
||||||
# TODO: Use PostgreSQL JSON containment query (@>) for production.
|
dialect_name = session.bind.dialect.name if session.bind else "default"
|
||||||
# Current approach loads all products into memory — acceptable for tests
|
if dialect_name == "postgresql":
|
||||||
# and small datasets, but will not scale.
|
stmt = select(NormalizedProduct).where(
|
||||||
stmt = select(NormalizedProduct).where(NormalizedProduct.upc_variants.is_not(None))
|
cast(NormalizedProduct.upc_variants, JSONB).op("@>")(
|
||||||
products = session.execute(stmt).scalars().all()
|
func.cast(json.dumps([upc]), JSONB)
|
||||||
for product in products:
|
)
|
||||||
if product.upc_variants and upc in product.upc_variants:
|
)
|
||||||
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
|
else:
|
||||||
|
stmt = select(NormalizedProduct).where(
|
||||||
|
NormalizedProduct.upc_variants.is_not(None),
|
||||||
|
cast(NormalizedProduct.upc_variants, String).contains(upc),
|
||||||
|
)
|
||||||
|
product = session.execute(stmt).scalars().first()
|
||||||
|
if product:
|
||||||
|
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user