chore: initial project setup with backend, frontend, and infrastructure
Some checks failed
CI / backend (push) Failing after 31s
CI / frontend (push) Successful in 40s
CI / docker (push) Has been skipped

Add complete NexaPantry application structure including:
- Docker Compose configuration with PostgreSQL, Redis, FastAPI backend, worker, frontend and Caddy
- Environment configuration template with database, auth, and service settings
- GitHub Actions CI workflow for backend/frontend linting, testing, auditing and Docker builds
- AGPL-3.0 license and comprehensive README with setup, development, and security documentation
- Backend
This commit is contained in:
2026-06-04 10:26:38 +02:00
commit 3792ca55e7
74 changed files with 13417 additions and 0 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import current_admin
from app.api.routes.auth import create_reset_token, send_invitation
from app.db.session import get_db
from app.models.entities import AppSetting, AuditLog, Home, Product, User
from app.schemas.common import (
MailSettingsIn,
MailSettingsOut,
Message,
TestMailIn,
UserCreate,
UserOut,
UserUpdate,
)
from app.services.audit import audit
from app.services.mail import (
get_mail_settings,
send_mail,
serialize_mail_settings,
update_mail_settings,
)
router = APIRouter(dependencies=[Depends(current_admin)])
@router.get("/dashboard")
def dashboard(db: Session = Depends(get_db)) -> dict:
return {
"users": db.scalar(select(func.count(User.id))),
"homes": db.scalar(select(func.count(Home.id))),
"products": db.scalar(select(func.count(Product.id))),
"active_users": db.scalar(select(func.count(User.id)).where(User.is_active)),
}
@router.get("/users", response_model=list[UserOut])
def list_users(db: Session = Depends(get_db)) -> list[User]:
return list(db.scalars(select(User).order_by(User.created_at.desc())).all())
@router.post("/users", response_model=UserOut, status_code=201)
def create_user(payload: UserCreate, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> User:
if db.scalar(select(User).where(User.email == str(payload.email).lower())):
raise HTTPException(status_code=409, detail="E-mail already exists")
user = User(email=str(payload.email).lower(), name=payload.name, instance_role=payload.role, is_active=True)
db.add(user)
db.flush()
if payload.send_invite:
send_invitation(db, user)
audit(db, admin, "admin.user.create", "user", user.id)
db.commit()
db.refresh(user)
return user
@router.patch("/users/{user_id}", response_model=UserOut)
def update_user(user_id: str, payload: UserUpdate, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> User:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(user, key, value)
audit(db, admin, "admin.user.update", "user", user.id)
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}", response_model=Message)
def delete_user(user_id: str, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> Message:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
audit(db, admin, "admin.user.delete", "user", user_id)
db.commit()
return Message(message="User deleted")
@router.post("/users/{user_id}/reset-password", response_model=Message)
def reset_password(user_id: str, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> Message:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = create_reset_token(db, user)
send_mail(db, user.email, "NexaPantry password reset", f"Reset token: {token}")
audit(db, admin, "admin.user.reset_password", "user", user.id)
db.commit()
return Message(message="Password reset mail sent")
@router.get("/homes")
def homes(db: Session = Depends(get_db)) -> list[dict]:
return [{"id": h.id, "name": h.name, "members": len(h.memberships), "products": len(h.products)} for h in db.scalars(select(Home)).all()]
@router.get("/mail", response_model=MailSettingsOut)
def mail_settings(db: Session = Depends(get_db)) -> MailSettingsOut:
return serialize_mail_settings(get_mail_settings(db))
@router.put("/mail", response_model=MailSettingsOut)
def save_mail_settings(payload: MailSettingsIn, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> MailSettingsOut:
settings = update_mail_settings(db, payload)
audit(db, admin, "admin.mail.update", "mail_settings", "1")
db.commit()
return serialize_mail_settings(settings)
@router.post("/mail/test", response_model=Message)
def test_mail(payload: TestMailIn, db: Session = Depends(get_db)) -> Message:
send_mail(db, str(payload.to), "NexaPantry test mail", "SMTP is configured correctly.")
return Message(message="Test mail sent")
@router.get("/settings")
def get_settings(db: Session = Depends(get_db)) -> dict:
return {s.key: s.value for s in db.scalars(select(AppSetting)).all()}
@router.put("/settings/{key}")
def set_setting(key: str, value: dict, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> dict:
setting = db.get(AppSetting, key) or AppSetting(key=key)
setting.value = value
db.merge(setting)
audit(db, admin, "admin.setting.update", "setting", key)
db.commit()
return setting.value
@router.get("/logs")
def logs(db: Session = Depends(get_db)) -> list[dict]:
rows = db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
return [{"created_at": r.created_at, "action": r.action, "target_type": r.target_type, "target_id": r.target_id, "metadata": r.metadata_json} for r in rows]
@router.get("/system")
def system_info() -> dict:
return {"app": "NexaPantry", "version": "0.1.0", "runtime": "FastAPI", "database": "PostgreSQL"}

View File

@@ -0,0 +1,117 @@
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user
from app.core.config import get_settings
from app.core.security import (
create_session_token,
hash_password,
hash_token,
make_csrf_token,
new_token,
verify_password,
)
from app.db.session import get_db
from app.models.entities import (
Home,
HomeMembership,
HomeRole,
InvitationToken,
PasswordResetToken,
User,
)
from app.schemas.common import InviteAccept, LoginRequest, Message, UserOut
from app.services.audit import audit
from app.services.mail import invite_body, send_mail
router = APIRouter()
def set_auth_cookies(response: Response, user_id: str) -> None:
settings = get_settings()
csrf = make_csrf_token()
response.set_cookie("np_session", create_session_token(user_id), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=60 * 60 * 24 * 14)
response.set_cookie("np_csrf", csrf, httponly=False, secure=settings.cookie_secure, samesite="lax", max_age=60 * 60 * 24 * 14)
@router.post("/login", response_model=UserOut)
def login(payload: LoginRequest, response: Response, db: Session = Depends(get_db)) -> User:
user = db.scalar(select(User).where(User.email == str(payload.email).lower()))
if not user or not verify_password(payload.password, user.password_hash) or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
set_auth_cookies(response, user.id)
audit(db, user, "auth.login", "user", user.id)
db.commit()
return user
@router.post("/logout", response_model=Message)
def logout(response: Response) -> Message:
response.delete_cookie("np_session")
response.delete_cookie("np_csrf")
return Message(message="Logged out")
@router.get("/me", response_model=UserOut)
def me(user: User = Depends(current_user)) -> User:
return user
@router.patch("/me", response_model=UserOut)
def update_me(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> User:
for field in ["name", "language", "theme", "timezone", "onboarding_completed"]:
if field in payload:
setattr(user, field, payload[field])
db.commit()
db.refresh(user)
return user
@router.post("/accept-invite", response_model=UserOut)
def accept_invite(payload: InviteAccept, response: Response, db: Session = Depends(get_db)) -> User:
hashed = hash_token(payload.token)
invite = db.scalar(select(InvitationToken).where(InvitationToken.token_hash == hashed))
if not invite or invite.consumed_at or invite.expires_at < datetime.now(UTC):
raise HTTPException(status_code=400, detail="Invalid or expired invitation")
user = db.get(User, invite.user_id)
if not user:
raise HTTPException(status_code=400, detail="Invalid invitation")
user.name = payload.name
user.password_hash = hash_password(payload.password)
user.language = payload.language
user.theme = payload.theme
invite.consumed_at = datetime.now(UTC)
if payload.home_name:
home = Home(name=payload.home_name)
db.add(home)
db.flush()
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
elif payload.join_code:
from app.api.routes.homes import join_home_by_code
join_home_by_code(payload.join_code, user, db)
audit(db, user, "auth.accept_invite", "user", user.id)
db.commit()
set_auth_cookies(response, user.id)
return user
def create_invitation(db: Session, user: User) -> str:
token = new_token()
db.add(InvitationToken(user_id=user.id, token_hash=hash_token(token), expires_at=datetime.now(UTC) + timedelta(days=7)))
return token
def create_reset_token(db: Session, user: User) -> str:
token = new_token()
db.add(PasswordResetToken(user_id=user.id, token_hash=hash_token(token), expires_at=datetime.now(UTC) + timedelta(hours=2)))
return token
def send_invitation(db: Session, user: User) -> None:
token = create_invitation(db, user)
send_mail(db, user.email, "Your NexaPantry invitation", invite_body(token))

View File

@@ -0,0 +1,117 @@
import secrets
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_owner
from app.core.security import hash_token
from app.db.session import get_db
from app.models.entities import Home, HomeMembership, HomeRole, User
from app.schemas.common import HomeCreate, HomeOut, HomeSettingsUpdate, JoinCodeOut, Message
from app.services.audit import audit
router = APIRouter()
def home_out(home: Home, role: str | None = None) -> HomeOut:
return HomeOut.model_validate(home).model_copy(update={"role": role})
@router.get("", response_model=list[HomeOut])
def list_homes(user: User = Depends(current_user)) -> list[HomeOut]:
return [home_out(m.home, m.role) for m in user.memberships]
@router.post("", response_model=HomeOut, status_code=201)
def create_home(payload: HomeCreate, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
home = Home(name=payload.name)
db.add(home)
db.flush()
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
audit(db, user, "home.create", "home", home.id)
db.commit()
db.refresh(home)
return home_out(home, HomeRole.OWNER)
def join_home_by_code(join_code: str, user: User, db: Session) -> Home:
home = db.scalar(select(Home).where(Home.join_code_hash == hash_token(join_code.upper())))
if not home or not home.join_code_expires_at or home.join_code_expires_at < datetime.now(UTC):
raise HTTPException(status_code=400, detail="Invalid join code")
exists = db.scalar(select(HomeMembership).where(HomeMembership.home_id == home.id, HomeMembership.user_id == user.id))
if not exists:
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.MEMBER))
return home
@router.post("/join", response_model=HomeOut)
def join(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
home = join_home_by_code(str(payload.get("join_code", "")), user, db)
audit(db, user, "home.join", "home", home.id)
db.commit()
return home_out(home, HomeRole.MEMBER)
@router.patch("/{home_id}", response_model=HomeOut)
def update_home(home_id: str, payload: HomeSettingsUpdate, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
membership = require_home_owner(home_id, db, user)
home = db.get(Home, home_id)
if not home:
raise HTTPException(status_code=404, detail="Home not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(home, key, value)
audit(db, user, "home.update", "home", home.id)
db.commit()
return home_out(home, membership.role)
@router.post("/{home_id}/join-code", response_model=JoinCodeOut)
def create_join_code(home_id: str, payload: dict | None = None, user: User = Depends(current_user), db: Session = Depends(get_db)) -> JoinCodeOut:
require_home_owner(home_id, db, user)
home = db.get(Home, home_id)
if not home:
raise HTTPException(status_code=404, detail="Home not found")
days = int((payload or {}).get("days", 7))
code = f"NX-{secrets.randbelow(900000) + 100000}"
home.join_code_hash = hash_token(code)
home.join_code_expires_at = datetime.now(UTC) + timedelta(days=max(1, min(days, 60)))
audit(db, user, "home.join_code.create", "home", home.id)
db.commit()
return JoinCodeOut(join_code=code, expires_at=home.join_code_expires_at)
@router.delete("/{home_id}/join-code", response_model=Message)
def disable_join_code(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_owner(home_id, db, user)
home = db.get(Home, home_id)
if not home:
raise HTTPException(status_code=404, detail="Home not found")
home.join_code_hash = None
home.join_code_expires_at = None
audit(db, user, "home.join_code.disable", "home", home.id)
db.commit()
return Message(message="Join code disabled")
@router.get("/{home_id}/members")
def members(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
require_home_owner(home_id, db, user)
rows = db.scalars(select(HomeMembership).where(HomeMembership.home_id == home_id)).all()
return [{"id": m.id, "user_id": m.user_id, "name": m.user.name, "email": m.user.email, "role": m.role} for m in rows]
@router.patch("/{home_id}/members/{membership_id}")
def update_member(home_id: str, membership_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
require_home_owner(home_id, db, user)
membership = db.get(HomeMembership, membership_id)
if not membership or membership.home_id != home_id:
raise HTTPException(status_code=404, detail="Member not found")
if payload.get("role") in [HomeRole.OWNER, HomeRole.MEMBER, HomeRole.READ_ONLY]:
membership.role = payload["role"]
if "notification_preferences" in payload:
membership.notification_preferences = payload["notification_preferences"]
audit(db, user, "home.member.update", "membership", membership.id)
db.commit()
return {"message": "Member updated"}

View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user
from app.db.session import get_db
from app.models.entities import Notification, User
from app.services.notifications import mark_read
router = APIRouter()
@router.get("")
def list_notifications(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.scalars(select(Notification).where(Notification.user_id == user.id).order_by(Notification.created_at.desc()).limit(100)).all()
return [{"id": n.id, "title": n.title, "body": n.body, "kind": n.kind, "read_at": n.read_at, "created_at": n.created_at} for n in rows]
@router.post("/{notification_id}/read")
def read(notification_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
notification = db.get(Notification, notification_id)
if not notification or notification.user_id != user.id:
raise HTTPException(status_code=404, detail="Notification not found")
mark_read(db, notification)
db.commit()
return {"message": "Notification read"}

View File

@@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_member, require_home_write
from app.db.session import get_db
from app.models.entities import Product, ShoppingItem, User
from app.schemas.common import Message, ProductIn, ProductOut
from app.services.audit import audit
from app.services.products import OpenFoodFactsLookup, expiry_status
router = APIRouter()
def serialize(product: Product) -> ProductOut:
return ProductOut.model_validate(product).model_copy(update={"status": expiry_status(product, product.home)})
@router.get("", response_model=list[ProductOut])
def list_products(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[ProductOut]:
require_home_member(home_id, db, user)
rows = db.scalars(select(Product).where(Product.home_id == home_id).order_by(Product.expires_at.nullslast(), Product.name)).all()
return [serialize(product) for product in rows]
@router.post("", response_model=ProductOut, status_code=201)
def create_product(home_id: str, payload: ProductIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ProductOut:
require_home_write(home_id, db, user)
product = Product(home_id=home_id, created_by_id=user.id, **payload.model_dump())
db.add(product)
audit(db, user, "product.create", "product", product.id)
db.commit()
db.refresh(product)
return serialize(product)
@router.patch("/{product_id}", response_model=ProductOut)
def update_product(home_id: str, product_id: str, payload: ProductIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ProductOut:
require_home_write(home_id, db, user)
product = db.get(Product, product_id)
if not product or product.home_id != home_id:
raise HTTPException(status_code=404, detail="Product not found")
for key, value in payload.model_dump().items():
setattr(product, key, value)
audit(db, user, "product.update", "product", product.id)
db.commit()
return serialize(product)
@router.delete("/{product_id}", response_model=Message)
def delete_product(home_id: str, product_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
product = db.get(Product, product_id)
if not product or product.home_id != home_id:
raise HTTPException(status_code=404, detail="Product not found")
db.delete(product)
audit(db, user, "product.delete", "product", product_id)
db.commit()
return Message(message="Product deleted")
@router.post("/{product_id}/add-to-shopping", response_model=Message)
def add_to_shopping(home_id: str, product_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
product = db.get(Product, product_id)
if not product or product.home_id != home_id:
raise HTTPException(status_code=404, detail="Product not found")
db.add(ShoppingItem(home_id=home_id, product_id=product.id, name=product.name, category=product.category, quantity=max(product.min_quantity - product.quantity, 1), unit=product.unit))
audit(db, user, "shopping.from_product", "product", product.id)
db.commit()
return Message(message="Added to shopping list")
@router.get("/lookup/{barcode}")
async def lookup_barcode(home_id: str, barcode: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
require_home_member(home_id, db, user)
result = await OpenFoodFactsLookup().by_barcode(barcode)
return {"found": bool(result), "product": result}

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_member
from app.db.session import get_db
from app.models.entities import Product, User
from app.services.recipes import suggest
router = APIRouter()
@router.get("")
def recipe_suggestions(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
require_home_member(home_id, db, user)
products = list(db.scalars(select(Product).where(Product.home_id == home_id)).all())
return suggest(products, user.language)

View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.db.session import get_db
from app.models.entities import AppSetting, Home, HomeMembership, HomeRole, InstanceRole, User
from app.schemas.common import SetupCreate, SetupStatus, UserOut
from app.services.audit import audit
router = APIRouter()
@router.get("/status", response_model=SetupStatus)
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
admin_exists = db.scalar(select(User).where(User.instance_role == InstanceRole.ADMIN)) is not None
instance = db.get(AppSetting, "instance")
return SetupStatus(needs_setup=not admin_exists, instance=instance.value if instance else None)
@router.post("/complete", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def complete_setup(payload: SetupCreate, db: Session = Depends(get_db)) -> User:
if db.scalar(select(User).where(User.instance_role == InstanceRole.ADMIN)):
raise HTTPException(status_code=409, detail="Instance already initialized")
user = User(
email=str(payload.email).lower(),
name=payload.name,
password_hash=hash_password(payload.password),
instance_role=InstanceRole.ADMIN,
language=payload.language,
theme=payload.theme,
timezone=payload.timezone,
)
home = Home(name=f"{payload.name}'s Home")
db.add_all(
[
user,
home,
AppSetting(
key="instance",
value={
"name": payload.instance_name,
"public_url": payload.public_url,
"language": payload.language,
"theme": payload.theme,
"timezone": payload.timezone,
"registration_enabled": False,
"security": {"login_rate_limit": "10/minute"},
"notifications": {"default_warning_days": 5},
},
),
]
)
db.flush()
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
audit(db, user, "instance.setup", "user", user.id)
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_member, require_home_write
from app.db.session import get_db
from app.models.entities import Product, ShoppingItem, User
from app.schemas.common import Message, ProductIn, ShoppingItemIn, ShoppingItemOut
from app.services.audit import audit
router = APIRouter()
@router.get("", response_model=list[ShoppingItemOut])
def list_items(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[ShoppingItem]:
require_home_member(home_id, db, user)
return list(db.scalars(select(ShoppingItem).where(ShoppingItem.home_id == home_id).order_by(ShoppingItem.checked, ShoppingItem.created_at.desc())).all())
@router.post("", response_model=ShoppingItemOut, status_code=201)
def create_item(home_id: str, payload: ShoppingItemIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ShoppingItem:
require_home_write(home_id, db, user)
item = ShoppingItem(home_id=home_id, **payload.model_dump())
db.add(item)
audit(db, user, "shopping.create", "shopping_item", item.id)
db.commit()
db.refresh(item)
return item
@router.patch("/{item_id}", response_model=ShoppingItemOut)
def update_item(home_id: str, item_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ShoppingItem:
require_home_write(home_id, db, user)
item = db.get(ShoppingItem, item_id)
if not item or item.home_id != home_id:
raise HTTPException(status_code=404, detail="Item not found")
for field in ["name", "category", "quantity", "unit", "checked"]:
if field in payload:
setattr(item, field, payload[field])
db.commit()
return item
@router.post("/{item_id}/move-to-inventory", response_model=Message)
def move_to_inventory(home_id: str, item_id: str, payload: ProductIn | None = None, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
item = db.get(ShoppingItem, item_id)
if not item or item.home_id != home_id:
raise HTTPException(status_code=404, detail="Item not found")
data = payload.model_dump() if payload else {"name": item.name, "category": item.category, "quantity": item.quantity, "unit": item.unit}
db.add(Product(home_id=home_id, created_by_id=user.id, **data))
item.checked = True
audit(db, user, "shopping.move_to_inventory", "shopping_item", item.id)
db.commit()
return Message(message="Moved to inventory")
@router.delete("/{item_id}", response_model=Message)
def delete_item(home_id: str, item_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
item = db.get(ShoppingItem, item_id)
if not item or item.home_id != home_id:
raise HTTPException(status_code=404, detail="Item not found")
db.delete(item)
db.commit()
return Message(message="Item deleted")