feat: bump client-services (accumulated feature work + deploy fixes)

Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -2,11 +2,13 @@ import os
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, ProductModifierGroup
from models.prep_zone import PrepZone
from models.order import OrderItem
from models.user import User
from schemas.product import (
@@ -14,9 +16,9 @@ from schemas.product import (
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
SubcategoryReorderItem, ParentGeneralReorderItem,
PreferenceSetCreate, ProductQuickOptionCreate,
CategoryReparentRequest,
CategoryReparentRequest, ModifierGroupCreate, ModifierGroupOut,
)
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_menu_manager
from services.sse_bus import broadcast_sync
router = APIRouter()
@@ -28,6 +30,25 @@ def _broadcast_products_changed():
IMAGE_DIR = "/app/data/product_images"
def _replace_modifier_groups(db, product, groups):
"""Recreate modifier groups and return a list of created DB objects (index-aligned)."""
for g in product.modifier_groups:
db.delete(g)
db.flush()
created = []
for i, g in enumerate(groups):
new_g = ProductModifierGroup(
product_id=product.id,
modifier_type=g.modifier_type,
name=g.name,
sort_order=i,
)
db.add(new_g)
db.flush()
created.append(new_g)
return created
def _replace_quick_options(db, product, quick_options):
for qo in product.quick_options:
db.delete(qo)
@@ -45,43 +66,52 @@ def _replace_quick_options(db, product, quick_options):
))
def _replace_options(db, product, options):
def _replace_options(db, product, options, group_id_map=None):
for opt in product.options:
db.delete(opt)
db.flush()
for opt in options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
resolved_group = group_id_map[opt.group_id] if (group_id_map and opt.group_id is not None and opt.group_id < len(group_id_map)) else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
multi_select=opt.multi_select,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
is_compact=opt.is_compact,
group_id=resolved_group,
))
def _replace_ingredients(db, product, ingredients):
def _replace_ingredients(db, product, ingredients, group_id_map=None):
for ing in product.ingredients:
db.delete(ing)
db.flush()
for ing in ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
resolved_group = group_id_map[ing.group_id] if (group_id_map and ing.group_id is not None and ing.group_id < len(group_id_map)) else None
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate], group_id_map=None):
for ps in product.preference_sets:
db.delete(ps)
db.flush()
for ps in sets:
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
resolved_group = group_id_map[ps.group_id] if (group_id_map and ps.group_id is not None and ps.group_id < len(group_id_map)) else None
new_set = ProductPreferenceSet(
product_id=product.id,
name=ps.name,
shared_subset=shared_json,
is_favorite=ps.is_favorite,
favorite_sort_order=ps.favorite_sort_order,
group_id=resolved_group,
allow_multi_select=ps.allow_multi_select,
allow_choice_quantity=ps.allow_choice_quantity,
)
db.add(new_set)
db.flush()
@@ -94,6 +124,7 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
extra_cost=ch.extra_cost,
sub_choices=sub_json,
disables_subset=ch.disables_subset,
is_compact=ch.is_compact,
)
db.add(choice)
db.flush()
@@ -104,13 +135,27 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
# ── Categories ────────────────────────────────────────────────────────────────
@router.get("/tags")
def list_all_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Return sorted unique tag strings across all products."""
rows = db.query(Product.tags).filter(Product.tags != None, Product.tags != "[]").all()
tag_set = set()
for (tags_json,) in rows:
try:
tags = json.loads(tags_json) if tags_json else []
tag_set.update(tags)
except Exception:
pass
return sorted(tag_set)
@router.get("/categories", response_model=List[CategoryOut])
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Category).order_by(Category.sort_order).all()
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
# sort_order is among siblings (same parent_id level)
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
cat = Category(
@@ -128,7 +173,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
@@ -138,7 +183,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Reorder sub-categories within their parent (sort_order among siblings)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
@@ -149,7 +194,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Update general_sort_order on parent categories (position of the General group)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
@@ -160,7 +205,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Move a category to a new parent (or promote to top-level if parent_id is null).
All products assigned to this category follow it automatically (no product updates needed).
"""
@@ -190,7 +235,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
@@ -203,7 +248,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
@@ -217,14 +262,31 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
@router.get("/", response_model=List[ProductOut])
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"):
has_dashboard = user.role in ("superadmin", "manager") or getattr(user, "perm_access_dashboard", False)
if not all or not has_dashboard:
# Waiters only see active, available products
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
return q.order_by(Product.sort_order, Product.id).all()
class BulkPrepZoneBody(BaseModel):
product_ids: List[int]
prep_zone_ids: List[int]
@router.post("/bulk-prep-zones", status_code=status.HTTP_204_NO_CONTENT)
def bulk_assign_prep_zones(body: BulkPrepZoneBody, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Replace the prep zone assignments for a list of products at once."""
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
products = db.query(Product).filter(Product.id.in_(body.product_ids)).all()
for p in products:
p.prep_zones = zones
db.commit()
_broadcast_products_changed()
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
for item in items:
product = db.query(Product).filter(Product.id == item.id).first()
if product:
@@ -234,15 +296,20 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags"})
if data.get("sort_order") == 0:
data["sort_order"] = db.query(Product).count()
if body.cost_breakdown is not None:
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
data["tags"] = json.dumps(body.tags) if body.tags is not None else None
product = Product(**data)
db.add(product)
db.flush()
# Assign prep zones
if body.prep_zone_ids:
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
product.prep_zones = zones
for i, qo in enumerate(body.quick_options):
db.add(ProductQuickOption(
product_id=product.id,
@@ -254,20 +321,27 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
favorite_sort_order=qo.favorite_sort_order,
is_compact=qo.is_compact,
))
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
group_id_map = [g.id for g in created_groups]
for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
resolved_group = group_id_map[opt.group_id] if (opt.group_id is not None and opt.group_id < len(group_id_map)) else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
multi_select=opt.multi_select,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
is_compact=opt.is_compact,
group_id=resolved_group,
))
for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
_replace_preference_sets(db, product, body.preference_sets)
resolved_group = group_id_map[ing.group_id] if (ing.group_id is not None and ing.group_id < len(group_id_map)) else None
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
db.commit()
db.refresh(product)
_broadcast_products_changed()
@@ -275,30 +349,46 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
@router.put("/{product_id}", response_model=ProductOut)
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
scalar_fields = body.model_dump(
exclude_none=True,
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"},
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags", "modifier_groups"},
)
for field, value in scalar_fields.items():
setattr(product, field, value)
# Always clear legacy per-product printer — routing is now via prep zones only
product.printer_zone_id = None
# Update prep zones if provided
if body.prep_zone_ids is not None:
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
product.prep_zones = zones
# cost_breakdown is a list of objects — serialize to JSON for storage
if body.cost_breakdown is not None:
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
elif "cost_breakdown" in body.model_fields_set:
# explicitly set to null — clear it
product.cost_breakdown = None
# tags is a list of strings — serialize to JSON for storage
if body.tags is not None:
product.tags = json.dumps(body.tags)
elif "tags" in body.model_fields_set:
product.tags = None
if body.quick_options is not None:
_replace_quick_options(db, product, body.quick_options)
# Modifier groups must be recreated before items so we have the ID map
group_id_map = None
if body.modifier_groups is not None:
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
group_id_map = [g.id for g in created_groups]
if body.options is not None:
_replace_options(db, product, body.options)
_replace_options(db, product, body.options, group_id_map)
if body.ingredients is not None:
_replace_ingredients(db, product, body.ingredients)
_replace_ingredients(db, product, body.ingredients, group_id_map)
if body.preference_sets is not None:
_replace_preference_sets(db, product, body.preference_sets)
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
db.commit()
db.refresh(product)
_broadcast_products_changed()
@@ -306,7 +396,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
@router.post("/{product_id}/image", response_model=ProductOut)
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
@@ -336,8 +426,55 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
return product
# ── Modifier Groups ───────────────────────────────────────────────────────────
@router.get("/{product_id}/modifier-groups", response_model=List[ModifierGroupOut])
def list_modifier_groups(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(ProductModifierGroup).filter(ProductModifierGroup.product_id == product_id).order_by(ProductModifierGroup.sort_order).all()
@router.post("/{product_id}/modifier-groups", response_model=ModifierGroupOut, status_code=status.HTTP_201_CREATED)
def create_modifier_group(product_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
g = ProductModifierGroup(product_id=product_id, modifier_type=body.modifier_type, name=body.name, sort_order=body.sort_order)
db.add(g)
db.commit()
db.refresh(g)
_broadcast_products_changed()
return g
@router.put("/{product_id}/modifier-groups/{group_id}", response_model=ModifierGroupOut)
def update_modifier_group(product_id: int, group_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
if not g:
raise HTTPException(status_code=404, detail="Group not found")
g.name = body.name
g.sort_order = body.sort_order
db.commit()
db.refresh(g)
_broadcast_products_changed()
return g
@router.delete("/{product_id}/modifier-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_modifier_group(product_id: int, group_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
if not g:
raise HTTPException(status_code=404, detail="Group not found")
# Un-group all items in this group before deleting
db.query(ProductOption).filter(ProductOption.group_id == group_id).update({"group_id": None})
db.query(ProductIngredient).filter(ProductIngredient.group_id == group_id).update({"group_id": None})
db.query(ProductPreferenceSet).filter(ProductPreferenceSet.group_id == group_id).update({"group_id": None})
db.delete(g)
db.commit()
_broadcast_products_changed()
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")