From 7a53cc3d669532d743a96f6c07a47877670dea4e Mon Sep 17 00:00:00 2001 From: bonamin Date: Sun, 19 Jul 2026 20:57:00 +0300 Subject: [PATCH] feat: receive and serve product images pushed from local sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New POST /api/menu/sync-image (site-authenticated) accepts a product's image file, stores it under /app/data/product_images keyed by (site, product_id), and skips the write entirely if the uploaded content hash matches what's already stored. GET /api/menu/{site_slug} now injects the cloud-hosted image URL into each product that has no manual digital_image_url override — replacing the local-only image_url from the snapshot, which was never reachable from the public internet. Also fixes the menu-app resolving product image URLs as bare relative paths instead of prefixing them with the cloud API origin (same class of bug as the earlier header-image fix). Co-Authored-By: Claude Sonnet 5 --- cloud_backend/main.py | 4 ++ cloud_backend/models/product_image.py | 19 +++++ cloud_backend/routers/menu.py | 72 ++++++++++++++++++- .../menu-app/src/pages/MenuPage.jsx | 9 ++- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 cloud_backend/models/product_image.py diff --git a/cloud_backend/main.py b/cloud_backend/main.py index 0df0459..996a0ea 100644 --- a/cloud_backend/main.py +++ b/cloud_backend/main.py @@ -13,6 +13,7 @@ import models.menu_snapshot # noqa: F401 import models.online_order # noqa: F401 import models.manager_account # noqa: F401 import models.stats_snapshot # noqa: F401 +import models.product_image # noqa: F401 from routers import auth, sites, heartbeat from routers import menu as menu_router @@ -90,6 +91,9 @@ app.include_router(remote_dashboard_router.router,prefix="/api/remote", tags= os.makedirs("/app/data/site_headers", exist_ok=True) app.mount("/static/site_headers", StaticFiles(directory="/app/data/site_headers"), name="site_headers") +os.makedirs("/app/data/product_images", exist_ok=True) +app.mount("/static/product_images", StaticFiles(directory="/app/data/product_images"), name="product_images") + @app.get("/health") def health(): diff --git a/cloud_backend/models/product_image.py b/cloud_backend/models/product_image.py new file mode 100644 index 0000000..bd6e1c3 --- /dev/null +++ b/cloud_backend/models/product_image.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.sql import func +from database import Base + + +class ProductImage(Base): + """Cloud-hosted copy of a local product's image, pushed by local_backend + during menu sync. Used as a fallback on the public QR menu when the + product has no digital_image_url override set.""" + __tablename__ = "product_images" + + id = Column(Integer, primary_key=True, index=True) + site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, index=True) + product_id = Column(Integer, nullable=False) + image_url = Column(String, nullable=False) + image_hash = Column(String, nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + __table_args__ = (UniqueConstraint("site_id", "product_id", name="uq_product_images_site_product"),) diff --git a/cloud_backend/routers/menu.py b/cloud_backend/routers/menu.py index 3a6cc2e..23b0cf9 100644 --- a/cloud_backend/routers/menu.py +++ b/cloud_backend/routers/menu.py @@ -1,15 +1,20 @@ -from fastapi import APIRouter, Depends, HTTPException, Header, status +import os +import uuid +from fastapi import APIRouter, Depends, HTTPException, Header, UploadFile, File, Form, status from passlib.context import CryptContext from sqlalchemy.orm import Session from database import get_db from models.site import Site from models.menu_snapshot import MenuSnapshot +from models.product_image import ProductImage from schemas.menu import MenuSyncRequest, MenuSyncResponse router = APIRouter() _pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") +PRODUCT_IMAGE_DIR = "/app/data/product_images" + def _require_site( x_site_id: str = Header(..., alias="X-Site-ID"), @@ -45,6 +50,20 @@ def get_menu(site_slug: str, db: Session = Depends(get_db)): "hours": site.menu_hours, "headerImageUrl": site.menu_header_image_url, } + + cloud_images = { + img.product_id: img.image_url + for img in db.query(ProductImage).filter(ProductImage.site_id == site.id).all() + } + for cat in data.get("categories", []): + for product in cat.get("products", []): + # image_url as pushed by local_backend points at the restaurant's own + # LAN/local server and isn't reachable from the internet — always + # replace it with the cloud-hosted copy (or None) unless a manual + # digital_image_url override is set. + if not product.get("digital_image_url"): + product["image_url"] = cloud_images.get(product.get("id")) + return data @@ -61,3 +80,54 @@ def sync_menu(body: MenuSyncRequest, site: Site = Depends(_require_site), db: Se db.add(snapshot) db.commit() return MenuSyncResponse(ok=True) + + +@router.post("/sync-image") +async def sync_product_image( + product_id: int = Form(...), + file: UploadFile = File(...), + site: Site = Depends(_require_site), + db: Session = Depends(get_db), +): + """Upload/replace the cloud-hosted copy of a product's image. Called by + local_backend during menu sync for products whose image changed.""" + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + contents = await file.read() + + import hashlib + image_hash = hashlib.sha256(contents).hexdigest() + + record = ( + db.query(ProductImage) + .filter(ProductImage.site_id == site.id, ProductImage.product_id == product_id) + .first() + ) + + os.makedirs(PRODUCT_IMAGE_DIR, exist_ok=True) + + if record and record.image_hash == image_hash: + return {"ok": True, "image_url": record.image_url, "unchanged": True} + + if record: + old_path = os.path.join(PRODUCT_IMAGE_DIR, os.path.basename(record.image_url)) + if os.path.exists(old_path): + os.remove(old_path) + + ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "jpg" + filename = f"{site.id}_{product_id}_{uuid.uuid4().hex[:8]}.{ext}" + filepath = os.path.join(PRODUCT_IMAGE_DIR, filename) + with open(filepath, "wb") as f: + f.write(contents) + + image_url = f"/static/product_images/{filename}" + if record: + record.image_url = image_url + record.image_hash = image_hash + else: + record = ProductImage(site_id=site.id, product_id=product_id, image_url=image_url, image_hash=image_hash) + db.add(record) + db.commit() + + return {"ok": True, "image_url": image_url, "unchanged": False} diff --git a/connect_frontend/menu-app/src/pages/MenuPage.jsx b/connect_frontend/menu-app/src/pages/MenuPage.jsx index 7a76e34..944e3e4 100644 --- a/connect_frontend/menu-app/src/pages/MenuPage.jsx +++ b/connect_frontend/menu-app/src/pages/MenuPage.jsx @@ -54,6 +54,13 @@ const I18N = { }, } +// Resolve a possibly-relative path (e.g. "/static/product_images/x.jpg") against +// the cloud API origin. Absolute URLs (manager-set external overrides) pass through untouched. +function resolveImageUrl(url) { + if (!url) return null + return /^https?:\/\//i.test(url) ? url : `${CLOUD_URL}${url}` +} + // ── Normalise a backend product to the shape the UI expects ────────────────── function normaliseProduct(p, catId) { return { @@ -68,7 +75,7 @@ function normaliseProduct(p, catId) { allergens: p.allergens || [], ingredients: p.ingredients || null, discountPct: p.digital_discount || p.discountPct || 0, - image_url: p.digital_image_url || p.image_url || null, + image_url: resolveImageUrl(p.digital_image_url || p.image_url || null), digital_available: p.digital_available !== false, } }