From 82e8f1007dcae56eea7852da2ffb3dc18376d0eb Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:17:04 -0400 Subject: [PATCH 01/22] Added WebhookCredential model, migrations, and notified flag to wishlist entries --- .../a3b4c5d6e7f8_add_webhook_credentials.py | 48 +++++++++++++++++++ ...9_add_verify_tls_to_webhook_credentials.py | 25 ++++++++++ backend/models/__init__.py | 2 + backend/models/user.py | 7 +-- backend/models/webhook_credential.py | 21 ++++++++ backend/models/wishlist.py | 1 + 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/versions/a3b4c5d6e7f8_add_webhook_credentials.py create mode 100644 backend/migrations/versions/b4c5d6e7f8a9_add_verify_tls_to_webhook_credentials.py create mode 100644 backend/models/webhook_credential.py diff --git a/backend/migrations/versions/a3b4c5d6e7f8_add_webhook_credentials.py b/backend/migrations/versions/a3b4c5d6e7f8_add_webhook_credentials.py new file mode 100644 index 0000000..3d1c587 --- /dev/null +++ b/backend/migrations/versions/a3b4c5d6e7f8_add_webhook_credentials.py @@ -0,0 +1,48 @@ +"""add webhook_credentials table and wishlist notified flag + +Revision ID: a3b4c5d6e7f8 +Revises: c3d4e5f6a7b8 +Create Date: 2026-09-11 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'a3b4c5d6e7f8' +down_revision = 'c3d4e5f6a7b8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'webhook_credentials', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('label', sa.String(length=100), nullable=False), + sa.Column('webhook_id', sa.String(length=64), nullable=False), + sa.Column('secret_hash', sa.String(length=64), nullable=False), + sa.Column('target_url', sa.Text(), nullable=True), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + op.f('ix_webhook_credentials_id'), 'webhook_credentials', ['id'], unique=False + ) + op.create_index( + op.f('ix_webhook_credentials_webhook_id'), 'webhook_credentials', ['webhook_id'], unique=True + ) + + op.add_column( + 'wishlist_entries', + sa.Column('notified', sa.Boolean(), nullable=False, server_default=sa.false()) + ) + + +def downgrade() -> None: + op.drop_column('wishlist_entries', 'notified') + op.drop_index('ix_webhook_credentials_id', table_name='webhook_credentials') + op.drop_index('ix_webhook_credentials_webhook_id', table_name='webhook_credentials') + op.drop_table('webhook_credentials') diff --git a/backend/migrations/versions/b4c5d6e7f8a9_add_verify_tls_to_webhook_credentials.py b/backend/migrations/versions/b4c5d6e7f8a9_add_verify_tls_to_webhook_credentials.py new file mode 100644 index 0000000..1ecf31e --- /dev/null +++ b/backend/migrations/versions/b4c5d6e7f8a9_add_verify_tls_to_webhook_credentials.py @@ -0,0 +1,25 @@ +"""add verify_tls to webhook_credentials + +Revision ID: b4c5d6e7f8a9 +Revises: a3b4c5d6e7f8 +Create Date: 2026-09-11 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'b4c5d6e7f8a9' +down_revision = 'a3b4c5d6e7f8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + 'webhook_credentials', + sa.Column('verify_tls', sa.Boolean(), nullable=False, server_default=sa.true()) + ) + + +def downgrade() -> None: + op.drop_column('webhook_credentials', 'verify_tls') diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 2145cdd..90e0eef 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -7,6 +7,7 @@ from models.wishlist import WishlistEntry from models.currency import ConvertedCurrency from models.price_history import PriceHistory +from models.webhook_credential import WebhookCredential __all__ = [ "User", @@ -19,4 +20,5 @@ "WishlistEntry", "ConvertedCurrency", "PriceHistory", + "WebhookCredential", ] diff --git a/backend/models/user.py b/backend/models/user.py index 84ebfd7..72f87ba 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -17,6 +17,7 @@ class User(Base): created_at = Column(DateTime(timezone=True), server_default=func.now(), default=lambda: datetime.now(timezone.utc)) preferred_currency = Column(String, default="usd", nullable=False) - collections = relationship("CollectionEntry", back_populates="owner", cascade="all, delete-orphan") - decks = relationship("Deck", back_populates="owner", cascade="all, delete-orphan") - wishlist = relationship("WishlistEntry", back_populates="owner", cascade="all, delete-orphan") + collections = relationship("CollectionEntry", back_populates="owner", cascade="all, delete-orphan") + decks = relationship("Deck", back_populates="owner", cascade="all, delete-orphan") + wishlist = relationship("WishlistEntry", back_populates="owner", cascade="all, delete-orphan") + webhooks = relationship("WebhookCredential", back_populates="owner", cascade="all, delete-orphan") diff --git a/backend/models/webhook_credential.py b/backend/models/webhook_credential.py new file mode 100644 index 0000000..79b8ae2 --- /dev/null +++ b/backend/models/webhook_credential.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base + + +class WebhookCredential(Base): + __tablename__ = "webhook_credentials" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + label = Column(String(100), nullable=False) + webhook_id = Column(String(64), unique=True, nullable=False, index=True) + secret_hash = Column(String(64), nullable=False) + target_url = Column(Text, nullable=True) + enabled = Column(Boolean, default=True, server_default='true', nullable=False) + verify_tls = Column(Boolean, default=True, server_default='true', nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + last_used_at = Column(DateTime(timezone=True), nullable=True) + + owner = relationship("User", back_populates="webhooks") diff --git a/backend/models/wishlist.py b/backend/models/wishlist.py index f2d422a..c99b36e 100644 --- a/backend/models/wishlist.py +++ b/backend/models/wishlist.py @@ -13,6 +13,7 @@ class WishlistEntry(Base): target_price = Column(Float, nullable=True) foil = Column(Boolean, default=False) notes = Column(Text, nullable=True) + notified = Column(Boolean, default=False, server_default='false', nullable=False) added_at = Column(DateTime(timezone=True), server_default=func.now()) __table_args__ = ( From 29616d004c392088e416268a96ce7745a1e81405 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:19:11 -0400 Subject: [PATCH 02/22] Extracted shared `compute_collection_stats` and added `color_identity_pct` --- backend/markets.py | 11 +++++++++++ backend/routers/collections.py | 27 ++++++++++++--------------- backend/tests/test_collections.py | 14 +++++++++++++- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/backend/markets.py b/backend/markets.py index f90755b..3544c04 100644 --- a/backend/markets.py +++ b/backend/markets.py @@ -12,3 +12,14 @@ "adapter": ScryfallMarket, }, } + + +def resolve_base_currency_and_rate(db, currency: str) -> tuple[str, float, str]: + if currency in MARKETS: + return currency, 1.0, currency + + from models import ConvertedCurrency + db_curr = db.query(ConvertedCurrency).filter_by(code=currency.upper()).first() + if db_curr and db_curr.rate: + return "usd", db_curr.rate, currency + return "usd", 1.0, "usd" diff --git a/backend/routers/collections.py b/backend/routers/collections.py index 3fcc5cd..746e9d7 100644 --- a/backend/routers/collections.py +++ b/backend/routers/collections.py @@ -9,7 +9,7 @@ import re from schemas import AddCardRequest, UpdateCardRequest, ImportResult, ImportRequest from constants import CONDITION_MULTIPLIERS -from markets import MARKETS +from markets import MARKETS, resolve_base_currency_and_rate import models import schemas @@ -180,21 +180,14 @@ def get_stats( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - uid = current_user.id - currency = current_user.preferred_currency + return compute_collection_stats(db, current_user) - if currency in MARKETS: - base_currency = currency - rate = 1.0 - else: - db_curr = db.query(models.ConvertedCurrency).filter_by(code=currency.upper()).first() - if db_curr and db_curr.rate: - base_currency = "usd" - rate = db_curr.rate - else: - currency = "usd" - base_currency = "usd" - rate = 1.0 + +def compute_collection_stats(db: Session, user: models.User) -> dict: + uid = user.id + currency = user.preferred_currency + + base_currency, rate, currency = resolve_base_currency_and_rate(db, currency) normal_col_name = f"price_{base_currency}" foil_col_name = f"price_{base_currency}_foil" @@ -408,6 +401,10 @@ def row_unit_price(r): }, "rarity": [{"name": r.rarity or "unknown", "count": r.count, "value": round(r.value or 0, 2)} for r in sorted(rarity_rows, key=lambda r: r.rarity or "")], "colors": [{"name": k, "count": v} for k, v in sorted(color_count.items(), key=lambda x: x[1], reverse=True)], + "color_identity_pct": { + k: round(v / sum(color_count.values()) * 100, 1) + for k, v in color_count.items() + } if color_count else {}, "types": [{"name": k, "count": v} for k, v in sorted(type_count.items(), key=lambda x: x[1], reverse=True)], "conditions": [{"name": r.condition or "Unknown", "count": r.count, "value": round(r.value or 0, 2)} for r in sorted(condition_rows, key=lambda r: r.condition or "")], "top_cards": top_cards, diff --git a/backend/tests/test_collections.py b/backend/tests/test_collections.py index 93c9313..8a43857 100644 --- a/backend/tests/test_collections.py +++ b/backend/tests/test_collections.py @@ -302,9 +302,21 @@ def test_stats_contains_expected_keys(self, client, db, regular_user): _add_entry(db, regular_user, card) stats = client.get("/collection/stats", headers=auth_headers(regular_user)).json() - for key in ("summary", "rarity", "colors", "types", "conditions", "top_cards", "top_sets"): + for key in ("summary", "rarity", "colors", "color_identity_pct", "types", "conditions", "top_cards", "top_sets"): assert key in stats + def test_color_identity_pct_sums_to_100(self, client, db, regular_user): + red = make_card(db, scryfall_id="r-1", name="Red Card", color_identity="R") + blue = make_card(db, scryfall_id="u-1", name="Blue Card", color_identity="U") + _add_entry(db, regular_user, red, quantity=3) + _add_entry(db, regular_user, blue, quantity=1) + + stats = client.get("/collection/stats", headers=auth_headers(regular_user)).json() + pct = stats["color_identity_pct"] + assert pct["Red"] == pytest.approx(75.0) + assert pct["Blue"] == pytest.approx(25.0) + assert sum(pct.values()) == pytest.approx(100.0) + # POST /collection/import (bulk import) From 921d40fa5b850de2933ca1e233d40f49b4e098ed Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:20:46 -0400 Subject: [PATCH 03/22] Added `services/webhooks.py` implementation for Home Assistant push events --- backend/services/webhooks.py | 98 ++++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/backend/services/webhooks.py b/backend/services/webhooks.py index f711690..7b6aacd 100644 --- a/backend/services/webhooks.py +++ b/backend/services/webhooks.py @@ -1,3 +1,95 @@ -def notify_trade_event(_event: str, _trade_id: int, _initiator: str, _counterpart: str): - """Placeholder for v1.10 Home Assistant webhook integration.""" - pass +import logging +import threading +from datetime import datetime, timezone + +import httpx + +import services.settings as settings_service + +logger = logging.getLogger(__name__) + +_TIMEOUT = 5.0 + + +def _post(credential_id: int, target_url: str, event: str, data: dict, verify_tls: bool = True) -> None: + def run(): + from database import SessionLocal + db = SessionLocal() + try: + payload = { + "event": event, + "timestamp": datetime.now(timezone.utc).isoformat(), + **data, + } + try: + with httpx.Client(timeout=_TIMEOUT, verify=verify_tls) as client: + r = client.post(target_url, json=payload) + logger.info(f"Home Assistant webhook '{event}' -> HTTP {r.status_code}") + except Exception as exc: + logger.info(f"Home Assistant webhook '{event}' failed (non-critical): {exc}") + return + + try: + import models + cred = db.get(models.WebhookCredential, credential_id) + if cred: + cred.last_used_at = datetime.now(timezone.utc) + db.commit() + except Exception as exc: + logger.info(f"Failed to update webhook last_used_at (non-critical): {exc}") + finally: + db.close() + + threading.Thread(target=run, daemon=True).start() + + +def _send_to_user(db, user, event: str, data: dict) -> None: + if settings_service.get(db, "home_assistant_integration_enabled") != "true": + return + if user is None: + return + + import models + credentials = ( + db.query(models.WebhookCredential) + .filter( + models.WebhookCredential.user_id == user.id, + models.WebhookCredential.enabled == True, # noqa: E712 + models.WebhookCredential.target_url.isnot(None), + models.WebhookCredential.target_url != "", + ) + .all() + ) + for cred in credentials: + _post(cred.id, cred.target_url, event, data, verify_tls=cred.verify_tls) + + +def notify_trade_event(db, trade, event: str, initiator, counterpart) -> None: + data = { + "trade_id": trade.id, + "initiator": initiator.username if initiator else "", + "counterpart": counterpart.username if counterpart else "", + } + _send_to_user(db, initiator, f"trade_{event}", data) + _send_to_user(db, counterpart, f"trade_{event}", data) + + +def notify_wishlist_target_met(db, entry, current_price: float, currency: str) -> None: + card = entry.card + data = { + "card_name": card.name, + "set_code": card.set_code, + "set_name": card.set_name, + "foil": entry.foil, + "target_price": entry.target_price, + "current_price": current_price, + "currency": currency, + } + _send_to_user(db, entry.owner, "wishlist_target_met", data) + + +def notify_test_event(credential) -> None: + if not credential.target_url: + return + _post(credential.id, credential.target_url, "test", {"label": credential.label}, + verify_tls=credential.verify_tls) From 7ebf1dc4d9e4dc36add28d397fd9034d163cdfe8 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:22:49 -0400 Subject: [PATCH 04/22] Added `/webhooks` router for managing webhook credentials --- backend/routers/webhook_credentials.py | 191 +++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 backend/routers/webhook_credentials.py diff --git a/backend/routers/webhook_credentials.py b/backend/routers/webhook_credentials.py new file mode 100644 index 0000000..7488991 --- /dev/null +++ b/backend/routers/webhook_credentials.py @@ -0,0 +1,191 @@ +import hashlib +import secrets + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from database import get_db +from security import get_current_user +import models +import services.settings as settings_service +import services.webhooks as webhooks + +router = APIRouter(prefix="/webhooks", tags=["webhooks"]) + + +class WebhookCredentialCreate(BaseModel): + label: str + target_url: str | None = None + verify_tls: bool = True + + +class WebhookCredentialUpdate(BaseModel): + label: str | None = None + target_url: str | None = None + enabled: bool | None = None + verify_tls: bool | None = None + + +def _require_enabled(db: Session) -> None: + if settings_service.get(db, "home_assistant_integration_enabled") != "true": + raise HTTPException(status_code=403, detail="Home Assistant integration is disabled") + + +def _get_own_credential(credential_id: int, current_user: models.User, db: Session) -> models.WebhookCredential: + cred = ( + db.query(models.WebhookCredential) + .filter_by(id=credential_id, user_id=current_user.id) + .first() + ) + if not cred: + raise HTTPException(status_code=404, detail="Webhook credential not found") + return cred + + +def _validate_target_url(target_url: str | None) -> None: + if target_url and not target_url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Target URL must start with http:// or https://") + + +def _serialize(cred: models.WebhookCredential) -> dict: + return { + "id": cred.id, + "label": cred.label, + "webhook_id": cred.webhook_id, + "target_url": cred.target_url, + "enabled": cred.enabled, + "verify_tls": cred.verify_tls, + "created_at": cred.created_at.isoformat() if cred.created_at else None, + "last_used_at": cred.last_used_at.isoformat() if cred.last_used_at else None, + } + + +@router.get("/status") +def webhook_status(db: Session = Depends(get_db)): + return { + "enabled": settings_service.get(db, "home_assistant_integration_enabled") == "true", + "max_per_user": settings_service.get_int(db, "home_assistant_max_credentials_per_user"), + } + + +@router.get("") +def list_credentials( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + creds = ( + db.query(models.WebhookCredential) + .filter_by(user_id=current_user.id) + .order_by(models.WebhookCredential.created_at.desc()) + .all() + ) + return [_serialize(c) for c in creds] + + +@router.post("", status_code=201) +def create_credential( + body: WebhookCredentialCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + _require_enabled(db) + _validate_target_url(body.target_url) + + max_per_user = settings_service.get_int(db, "home_assistant_max_credentials_per_user") + existing = db.query(models.WebhookCredential).filter_by(user_id=current_user.id).count() + if existing >= max_per_user: + raise HTTPException( + status_code=400, + detail=f"Maximum of {max_per_user} webhook credentials reached", + ) + + if not body.label.strip(): + raise HTTPException(status_code=400, detail="Label is required") + + secret = secrets.token_urlsafe(32) + cred = models.WebhookCredential( + user_id=current_user.id, + label=body.label.strip(), + webhook_id=secrets.token_urlsafe(24), + secret_hash=hashlib.sha256(secret.encode()).hexdigest(), + target_url=body.target_url or None, + verify_tls=body.verify_tls, + ) + db.add(cred) + db.commit() + db.refresh(cred) + + return { + **_serialize(cred), + "secret": secret, + "inbound_url": f"/api/webhook/{current_user.username}/{cred.webhook_id}/stats", + } + + +@router.patch("/{credential_id}") +def update_credential( + credential_id: int, + body: WebhookCredentialUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cred = _get_own_credential(credential_id, current_user, db) + + if body.target_url is not None: + _validate_target_url(body.target_url) + cred.target_url = body.target_url or None + if body.label is not None: + if not body.label.strip(): + raise HTTPException(status_code=400, detail="Label is required") + cred.label = body.label.strip() + if body.enabled is not None: + cred.enabled = body.enabled + if body.verify_tls is not None: + cred.verify_tls = body.verify_tls + + db.commit() + db.refresh(cred) + return _serialize(cred) + + +@router.delete("/{credential_id}", status_code=204) +def delete_credential( + credential_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cred = _get_own_credential(credential_id, current_user, db) + db.delete(cred) + db.commit() + + +@router.post("/{credential_id}/regenerate-secret") +def regenerate_secret( + credential_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cred = _get_own_credential(credential_id, current_user, db) + + secret = secrets.token_urlsafe(32) + cred.secret_hash = hashlib.sha256(secret.encode()).hexdigest() + db.commit() + db.refresh(cred) + + return {**_serialize(cred), "secret": secret} + + +@router.post("/{credential_id}/test") +def test_credential( + credential_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + _require_enabled(db) + cred = _get_own_credential(credential_id, current_user, db) + if not cred.target_url: + raise HTTPException(status_code=400, detail="No target URL configured for this credential") + + webhooks.notify_test_event(cred) + return {"message": "Test event sent"} From b02c10bef59d67c1905f84ad3d644f80928afcfe Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:24:25 -0400 Subject: [PATCH 05/22] Added `/webhook` pull endpoint for Home Assistant stats --- backend/routers/webhook_inbound.py | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/routers/webhook_inbound.py diff --git a/backend/routers/webhook_inbound.py b/backend/routers/webhook_inbound.py new file mode 100644 index 0000000..267e7d9 --- /dev/null +++ b/backend/routers/webhook_inbound.py @@ -0,0 +1,57 @@ +import hashlib +import hmac +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from sqlalchemy.orm import Session, joinedload + +from database import get_db +from limiter import limiter +import models +import services.settings as settings_service +from routers.collections import compute_collection_stats + +router = APIRouter(prefix="/webhook", tags=["webhook"]) + + +def _authenticate( + db: Session, username: str, webhook_id: str, authorization: str | None +) -> models.WebhookCredential: + if settings_service.get(db, "home_assistant_integration_enabled") != "true": + raise HTTPException(status_code=503, detail="Home Assistant integration is disabled") + + cred = ( + db.query(models.WebhookCredential) + .options(joinedload(models.WebhookCredential.owner)) + .filter_by(webhook_id=webhook_id) + .first() + ) + if not cred or not cred.enabled or not cred.owner or cred.owner.username != username: + raise HTTPException(status_code=404, detail="Webhook not found") + + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Missing bearer token") + + presented = authorization.removeprefix("Bearer ").strip() + presented_hash = hashlib.sha256(presented.encode()).hexdigest() + if not hmac.compare_digest(presented_hash, cred.secret_hash): + raise HTTPException(status_code=401, detail="Invalid bearer token") + + return cred + + +@router.get("/{username}/{webhook_id}/stats") +@limiter.limit("30/minute") +def pull_stats( + request: Request, + username: str, + webhook_id: str, + db: Session = Depends(get_db), + authorization: str | None = Header(default=None), +): + cred = _authenticate(db, username, webhook_id, authorization) + + cred.last_used_at = datetime.now(timezone.utc) + db.commit() + + return compute_collection_stats(db, cred.owner) From 0998ee162b1d486fabcd77658a7c5ed9c67c4ef5 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:25:22 -0400 Subject: [PATCH 06/22] Registered webhook routers --- backend/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index 06beeda..daeb308 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded -from routers import auth, cards, collections, currencies, converted_currencies, decks, export, admin, settings, telemetry, wishlist, showroom, scanner, trades +from routers import auth, cards, collections, currencies, converted_currencies, decks, export, admin, settings, telemetry, wishlist, showroom, scanner, trades, webhook_credentials, webhook_inbound from limiter import limiter from services.price_refresh import start_scheduler as start_price_scheduler from services.telemetry import start_scheduler as start_telemetry_scheduler @@ -36,6 +36,8 @@ async def lifespan(app: FastAPI): app.include_router(showroom.router) app.include_router(scanner.router) app.include_router(trades.router) +app.include_router(webhook_credentials.router) +app.include_router(webhook_inbound.router) @app.get("/health") def health(): From 8a254fff01c863d782a72dbafb55b89db9c77efb Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:27:15 -0400 Subject: [PATCH 07/22] Added `home_assistant_integration_enabled` and per-user credential cap settings --- backend/routers/settings.py | 14 ++++++++++++++ backend/schemas.py | 4 +++- backend/services/settings.py | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/routers/settings.py b/backend/routers/settings.py index dd4df89..cd96709 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -45,6 +45,20 @@ def update_settings(payload: SettingsUpdate, db: Session = Depends(get_db)): if payload.trades_enabled is not None: settings_service.set_value(db, "trades_enabled", "true" if payload.trades_enabled else "false") + if payload.home_assistant_integration_enabled is not None: + settings_service.set_value( + db, "home_assistant_integration_enabled", + "true" if payload.home_assistant_integration_enabled else "false", + ) + + if payload.home_assistant_max_credentials_per_user is not None: + if not 1 <= payload.home_assistant_max_credentials_per_user <= 20: + raise HTTPException(status_code=400, detail="Max credentials per user must be between 1 and 20") + settings_service.set_value( + db, "home_assistant_max_credentials_per_user", + str(payload.home_assistant_max_credentials_per_user), + ) + return settings_service.get_all(db) diff --git a/backend/schemas.py b/backend/schemas.py index a00567c..b8f1e57 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -300,4 +300,6 @@ class SettingsUpdate(BaseModel): price_history_days: int | None = None showroom_enabled: bool | None = None card_search_enabled: bool | None = None - trades_enabled: bool | None = None \ No newline at end of file + trades_enabled: bool | None = None + home_assistant_integration_enabled: bool | None = None + home_assistant_max_credentials_per_user: int | None = None \ No newline at end of file diff --git a/backend/services/settings.py b/backend/services/settings.py index c46cea3..2599b05 100644 --- a/backend/services/settings.py +++ b/backend/services/settings.py @@ -11,6 +11,8 @@ "showroom_enabled": "true", "card_search_enabled": "true", "trades_enabled": "true", + "home_assistant_integration_enabled": "false", + "home_assistant_max_credentials_per_user": "3", } _cache: dict | None = None From ff8b1f3fba674bee6575b353f6b0dccad4e40384 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:29:05 -0400 Subject: [PATCH 08/22] Wired trade events to Home Assistant webhook service --- backend/routers/trades.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/routers/trades.py b/backend/routers/trades.py index 3142d14..896235e 100644 --- a/backend/routers/trades.py +++ b/backend/routers/trades.py @@ -182,8 +182,7 @@ def propose_trade( db_trades.commit() db_trades.refresh(trade) - webhooks.notify_trade_event("proposed", trade.id, - current_user.username, counterpart.username) + webhooks.notify_trade_event(db_main, trade, "proposed", current_user, counterpart) return _build_trade_out(trade, current_user.id, db_main) @@ -243,7 +242,8 @@ def update_items( **snap, )) - if trade.status == "proposed" and current_user.id == trade.counterpart_id: + became_active = trade.status == "proposed" and current_user.id == trade.counterpart_id + if became_active: trade.status = "active" trade.initiator_confirmed = False @@ -252,6 +252,12 @@ def update_items( db_trades.commit() db_trades.refresh(trade) + + initiator, counterpart = _resolve_users(trade, db_main) + webhooks.notify_trade_event(db_main, trade, "updated", initiator, counterpart) + if became_active: + webhooks.notify_trade_event(db_main, trade, "engaged", initiator, counterpart) + return _build_trade_out(trade, current_user.id, db_main) @@ -282,9 +288,7 @@ def confirm_trade( _execute_transfer(trade, db_trades, db_main) trade.status = "accepted" initiator, counterpart = _resolve_users(trade, db_main) - webhooks.notify_trade_event("accepted", trade.id, - initiator.username if initiator else "", - counterpart.username if counterpart else "") + webhooks.notify_trade_event(db_main, trade, "accepted", initiator, counterpart) db_trades.commit() db_trades.refresh(trade) @@ -398,9 +402,7 @@ def reject_trade( trade.last_actor_id = current_user.id initiator, counterpart = _resolve_users(trade, db_main) - webhooks.notify_trade_event(trade.status, trade.id, - initiator.username if initiator else "", - counterpart.username if counterpart else "") + webhooks.notify_trade_event(db_main, trade, trade.status, initiator, counterpart) db_trades.commit() db_trades.refresh(trade) From 88ea948de0a78a7358433d6b82e8582b5d846417 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:32:10 -0400 Subject: [PATCH 09/22] Added wishlist `target-price` webhook check to price refresh --- backend/services/price_refresh.py | 45 +++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/backend/services/price_refresh.py b/backend/services/price_refresh.py index adf7d15..1d53246 100644 --- a/backend/services/price_refresh.py +++ b/backend/services/price_refresh.py @@ -2,11 +2,12 @@ import logging import threading from datetime import datetime, timezone, timedelta -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from database import SessionLocal import models import services.settings as settings_service -from markets import MARKETS +import services.webhooks as webhooks +from markets import MARKETS, resolve_base_currency_and_rate from services.scryfall_queue import scryfall_queue, Priority logger = logging.getLogger(__name__) @@ -25,6 +26,41 @@ def _purge_old_history(db: Session) -> None: logger.info(f"Purged {deleted} price history rows older than {days} days") +def _check_wishlist_targets(db: Session) -> None: + entries = ( + db.query(models.WishlistEntry) + .join(models.WishlistEntry.card) + .options(joinedload(models.WishlistEntry.card), joinedload(models.WishlistEntry.owner)) + .filter(models.WishlistEntry.target_price.isnot(None)) + .all() + ) + + for entry in entries: + owner = entry.owner + card = entry.card + if owner is None: + continue + + currency = owner.preferred_currency or "usd" + base_currency, rate, display_currency = resolve_base_currency_and_rate(db, currency) + + foil_price = getattr(card, f"price_{base_currency}_foil", None) + normal_price = getattr(card, f"price_{base_currency}", None) + base_price = foil_price if (entry.foil and foil_price is not None) else normal_price + current_price = (base_price * rate) if base_price is not None else None + + met = current_price is not None and current_price <= entry.target_price + if met and not entry.notified: + webhooks.notify_wishlist_target_met( + db, entry, round(current_price, 2), display_currency.upper() + ) + entry.notified = True + elif not met and entry.notified: + entry.notified = False + + db.commit() + + def _record_price_history(db: Session, card: models.Card) -> None: fields = {"card_id": card.id} for currency in MARKETS: @@ -114,6 +150,11 @@ def refresh_card_prices(db: Session) -> None: except Exception as e: logger.warning(f"Exchange rate refresh failed (non-critical): {e}") + try: + _check_wishlist_targets(db) + except Exception as e: + logger.warning(f"Wishlist target check failed (non-critical): {e}") + try: _purge_old_history(db) except Exception as e: From 7d04305d7b0ec53b65fd71ae81dd894542930ac1 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:34:48 -0400 Subject: [PATCH 10/22] Reset wishlist notified flag when target price, card, or foil changes --- backend/routers/wishlist.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/routers/wishlist.py b/backend/routers/wishlist.py index 3c7b402..cefbbf3 100644 --- a/backend/routers/wishlist.py +++ b/backend/routers/wishlist.py @@ -125,6 +125,10 @@ def update_wishlist_entry( if conflict: raise HTTPException(status_code=409, detail="That card is already on your wishlist") + if (new_card_id != entry.card_id or new_foil != entry.foil + or (body.target_price is not None and body.target_price != entry.target_price)): + entry.notified = False + entry.card_id = new_card_id entry.foil = new_foil if body.target_price is not None: From 352be2ccf199368fbde3a50e036d28886e39b26e Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:36:06 -0400 Subject: [PATCH 11/22] Fixed settings cache leaking between tests --- backend/tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0ad4023..f0dd544 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -43,6 +43,14 @@ def isolate_config_path(tmp_path, monkeypatch): monkeypatch.setenv("CONFIG_PATH", str(tmp_path)) +@pytest.fixture(autouse=True) +def reset_settings_cache(): + import services.settings as settings_service + settings_service._cache = None + yield + settings_service._cache = None + + @pytest.fixture() def db(): session = TestingSessionLocal() From dda9177b9ad66bad0284bc17557510ec70c8d707 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:39:50 -0400 Subject: [PATCH 12/22] Added test coverage for webhook credential management and the pull endpoint --- backend/tests/test_webhook_credentials.py | 238 ++++++++++++++++++++++ backend/tests/test_webhook_inbound.py | 132 ++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 backend/tests/test_webhook_credentials.py create mode 100644 backend/tests/test_webhook_inbound.py diff --git a/backend/tests/test_webhook_credentials.py b/backend/tests/test_webhook_credentials.py new file mode 100644 index 0000000..dc1c1d8 --- /dev/null +++ b/backend/tests/test_webhook_credentials.py @@ -0,0 +1,238 @@ +from conftest import auth_headers + + +def enable_ha(client, admin_headers, max_per_user=None): + payload = {"home_assistant_integration_enabled": True} + if max_per_user is not None: + payload["home_assistant_max_credentials_per_user"] = max_per_user + r = client.patch("/admin/settings", json=payload, headers=admin_headers) + assert r.status_code == 200 + + +# Auth guard + +class TestWebhookCredentialsAuthGuard: + def test_list_unauthenticated_returns_401(self, client): + assert client.get("/webhooks").status_code == 401 + + def test_create_unauthenticated_returns_401(self, client): + assert client.post("/webhooks", json={"label": "x"}).status_code == 401 + + def test_status_is_public(self, client): + assert client.get("/webhooks/status").status_code == 200 + + +# Status + +class TestWebhookStatus: + def test_default_disabled(self, client): + r = client.get("/webhooks/status") + assert r.json() == {"enabled": False, "max_per_user": 3} + + def test_reflects_admin_toggle(self, client, admin_headers): + enable_ha(client, admin_headers, max_per_user=5) + r = client.get("/webhooks/status") + assert r.json() == {"enabled": True, "max_per_user": 5} + + +# Create + +class TestCreateCredential: + def test_blocked_when_integration_disabled(self, client, regular_headers): + r = client.post("/webhooks", json={"label": "Home"}, headers=regular_headers) + assert r.status_code == 403 + + def test_create_returns_secret_and_inbound_url(self, client, admin_headers, regular_headers, regular_user): + enable_ha(client, admin_headers) + r = client.post( + "/webhooks", + json={"label": "Living Room HA", "target_url": "https://ha.example.com/api/webhook/abc"}, + headers=regular_headers, + ) + assert r.status_code == 201 + body = r.json() + assert body["label"] == "Living Room HA" + assert "secret" in body and len(body["secret"]) > 20 + assert body["inbound_url"] == f"/api/webhook/{regular_user.username}/{body['webhook_id']}/stats" + assert body["verify_tls"] is True + + def test_secret_not_in_list_response(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + client.post("/webhooks", json={"label": "Home"}, headers=regular_headers) + r = client.get("/webhooks", headers=regular_headers) + assert r.status_code == 200 + for cred in r.json(): + assert "secret" not in cred + assert "secret_hash" not in cred + + def test_rejects_non_http_target_url(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + r = client.post( + "/webhooks", json={"label": "Home", "target_url": "ftp://bad"}, headers=regular_headers + ) + assert r.status_code == 400 + + def test_blank_label_rejected(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + r = client.post("/webhooks", json={"label": " "}, headers=regular_headers) + assert r.status_code == 400 + + def test_target_url_optional(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + r = client.post("/webhooks", json={"label": "Pull only"}, headers=regular_headers) + assert r.status_code == 201 + assert r.json()["target_url"] is None + + def test_verify_tls_defaults_true(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + r = client.post("/webhooks", json={"label": "Home"}, headers=regular_headers) + assert r.json()["verify_tls"] is True + + def test_verify_tls_can_be_disabled_at_creation(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + r = client.post( + "/webhooks", + json={"label": "Self-signed HA", "target_url": "https://home.lan/api/webhook/x", "verify_tls": False}, + headers=regular_headers, + ) + assert r.status_code == 201 + assert r.json()["verify_tls"] is False + + def test_per_user_cap_enforced(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers, max_per_user=2) + for i in range(2): + r = client.post("/webhooks", json={"label": f"Cred {i}"}, headers=regular_headers) + assert r.status_code == 201 + r = client.post("/webhooks", json={"label": "One too many"}, headers=regular_headers) + assert r.status_code == 400 + + def test_cap_is_per_user_not_global(self, client, admin_headers, regular_headers, db): + enable_ha(client, admin_headers, max_per_user=1) + r = client.post("/webhooks", json={"label": "Mine"}, headers=regular_headers) + assert r.status_code == 201 + + from conftest import make_user + other = make_user(db, username="other", email="other@example.com") + r = client.post("/webhooks", json={"label": "Theirs"}, headers=auth_headers(other)) + assert r.status_code == 201 + + +# List / scoping + +class TestListCredentials: + def test_only_sees_own_credentials(self, client, admin_headers, regular_headers, db): + enable_ha(client, admin_headers) + client.post("/webhooks", json={"label": "Mine"}, headers=regular_headers) + + from conftest import make_user + other = make_user(db, username="other", email="other@example.com") + client.post("/webhooks", json={"label": "Theirs"}, headers=auth_headers(other)) + + r = client.get("/webhooks", headers=regular_headers) + labels = [c["label"] for c in r.json()] + assert labels == ["Mine"] + + +# Update / delete / regenerate + +class TestUpdateDeleteRegenerate: + def _create(self, client, headers): + r = client.post("/webhooks", json={"label": "Home"}, headers=headers) + return r.json() + + def test_update_label_and_target_url(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = self._create(client, regular_headers) + r = client.patch( + f"/webhooks/{cred['id']}", + json={"label": "Renamed", "target_url": "https://new.example.com/hook"}, + headers=regular_headers, + ) + assert r.status_code == 200 + assert r.json()["label"] == "Renamed" + assert r.json()["target_url"] == "https://new.example.com/hook" + + def test_toggle_verify_tls(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = self._create(client, regular_headers) + r = client.patch(f"/webhooks/{cred['id']}", json={"verify_tls": False}, headers=regular_headers) + assert r.status_code == 200 + assert r.json()["verify_tls"] is False + + def test_disable_toggle(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = self._create(client, regular_headers) + r = client.patch(f"/webhooks/{cred['id']}", json={"enabled": False}, headers=regular_headers) + assert r.json()["enabled"] is False + + def test_update_other_users_credential_returns_404(self, client, admin_headers, regular_headers, db): + enable_ha(client, admin_headers) + cred = self._create(client, regular_headers) + + from conftest import make_user + other = make_user(db, username="other", email="other@example.com") + r = client.patch(f"/webhooks/{cred['id']}", json={"label": "hijacked"}, headers=auth_headers(other)) + assert r.status_code == 404 + + def test_delete_credential(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = self._create(client, regular_headers) + r = client.delete(f"/webhooks/{cred['id']}", headers=regular_headers) + assert r.status_code == 204 + assert client.get("/webhooks", headers=regular_headers).json() == [] + + def test_regenerate_secret_changes_it_and_keeps_webhook_id(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = self._create(client, regular_headers) + r = client.post(f"/webhooks/{cred['id']}/regenerate-secret", headers=regular_headers) + assert r.status_code == 200 + body = r.json() + assert body["secret"] != cred["secret"] + assert body["webhook_id"] == cred["webhook_id"] + + def test_management_allowed_even_when_integration_later_disabled(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = self._create(client, regular_headers) + client.patch("/admin/settings", json={"home_assistant_integration_enabled": False}, headers=admin_headers) + + assert client.get("/webhooks", headers=regular_headers).status_code == 200 + assert client.delete(f"/webhooks/{cred['id']}", headers=regular_headers).status_code == 204 + + +# Test-push endpoint + +class TestTestPush: + def test_requires_target_url(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = client.post("/webhooks", json={"label": "Pull only"}, headers=regular_headers).json() + r = client.post(f"/webhooks/{cred['id']}/test", headers=regular_headers) + assert r.status_code == 400 + + def test_blocked_when_integration_disabled(self, client, admin_headers, regular_headers): + enable_ha(client, admin_headers) + cred = client.post( + "/webhooks", json={"label": "Home", "target_url": "https://ha.example.com/hook"}, + headers=regular_headers, + ).json() + client.patch("/admin/settings", json={"home_assistant_integration_enabled": False}, headers=admin_headers) + r = client.post(f"/webhooks/{cred['id']}/test", headers=regular_headers) + assert r.status_code == 403 + + def test_fires_and_returns_200(self, client, admin_headers, regular_headers, monkeypatch): + enable_ha(client, admin_headers) + cred = client.post( + "/webhooks", json={"label": "Home", "target_url": "https://ha.example.com/hook"}, + headers=regular_headers, + ).json() + + calls = [] + monkeypatch.setattr( + "services.webhooks._post", + lambda credential_id, target_url, event, data, verify_tls=True: calls.append( + (credential_id, target_url, event, data, verify_tls) + ), + ) + r = client.post(f"/webhooks/{cred['id']}/test", headers=regular_headers) + assert r.status_code == 200 + assert len(calls) == 1 + assert calls[0][2] == "test" diff --git a/backend/tests/test_webhook_inbound.py b/backend/tests/test_webhook_inbound.py new file mode 100644 index 0000000..d07f5f1 --- /dev/null +++ b/backend/tests/test_webhook_inbound.py @@ -0,0 +1,132 @@ +from unittest.mock import patch + +import pytest +from limiter import limiter +from conftest import make_card + + +@pytest.fixture(autouse=True) +def reset_limiter(): + limiter._storage.reset() + yield + limiter._storage.reset() + + +def enable_ha(client, admin_headers): + r = client.patch( + "/admin/settings", json={"home_assistant_integration_enabled": True}, headers=admin_headers + ) + assert r.status_code == 200 + + +def create_credential(client, headers, target_url=None): + r = client.post("/webhooks", json={"label": "HA", "target_url": target_url}, headers=headers) + assert r.status_code == 201 + return r.json() + + +class TestInboundStatsAuth: + def test_valid_bearer_returns_stats(self, client, admin_headers, regular_headers, regular_user, db): + enable_ha(client, admin_headers) + card = make_card(db) + with patch("services.scryfall.get_card_by_scryfall_id", return_value=card): + client.post("/collection", json={"scryfall_id": card.scryfall_id, "quantity": 1}, headers=regular_headers) + cred = create_credential(client, regular_headers) + + r = client.get( + f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats", + headers={"Authorization": f"Bearer {cred['secret']}"}, + ) + assert r.status_code == 200 + assert "summary" in r.json() + + def test_matches_authenticated_stats_endpoint(self, client, admin_headers, regular_headers, regular_user, db): + enable_ha(client, admin_headers) + card = make_card(db) + with patch("services.scryfall.get_card_by_scryfall_id", return_value=card): + client.post("/collection", json={"scryfall_id": card.scryfall_id, "quantity": 2}, headers=regular_headers) + + cred = create_credential(client, regular_headers) + via_webhook = client.get( + f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats", + headers={"Authorization": f"Bearer {cred['secret']}"}, + ).json() + via_normal = client.get("/collection/stats", headers=regular_headers).json() + assert via_webhook == via_normal + + def test_missing_bearer_returns_401(self, client, admin_headers, regular_headers, regular_user): + enable_ha(client, admin_headers) + cred = create_credential(client, regular_headers) + r = client.get(f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats") + assert r.status_code == 401 + + def test_wrong_secret_returns_401(self, client, admin_headers, regular_headers, regular_user): + enable_ha(client, admin_headers) + cred = create_credential(client, regular_headers) + r = client.get( + f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats", + headers={"Authorization": "Bearer wrong-secret"}, + ) + assert r.status_code == 401 + + def test_unknown_webhook_id_returns_404(self, client, admin_headers, regular_user): + enable_ha(client, admin_headers) + r = client.get( + f"/webhook/{regular_user.username}/does-not-exist/stats", + headers={"Authorization": "Bearer whatever"}, + ) + assert r.status_code == 404 + + def test_username_mismatch_returns_404(self, client, admin_headers, regular_headers, db): + enable_ha(client, admin_headers) + cred = create_credential(client, regular_headers) + r = client.get( + f"/webhook/someone-else/{cred['webhook_id']}/stats", + headers={"Authorization": f"Bearer {cred['secret']}"}, + ) + assert r.status_code == 404 + + def test_disabled_credential_returns_404(self, client, admin_headers, regular_headers, regular_user): + enable_ha(client, admin_headers) + cred = create_credential(client, regular_headers) + client.patch(f"/webhooks/{cred['id']}", json={"enabled": False}, headers=regular_headers) + r = client.get( + f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats", + headers={"Authorization": f"Bearer {cred['secret']}"}, + ) + assert r.status_code == 404 + + def test_integration_disabled_returns_503(self, client, admin_headers, regular_headers, regular_user): + enable_ha(client, admin_headers) + cred = create_credential(client, regular_headers) + client.patch("/admin/settings", json={"home_assistant_integration_enabled": False}, headers=admin_headers) + r = client.get( + f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats", + headers={"Authorization": f"Bearer {cred['secret']}"}, + ) + assert r.status_code == 503 + + def test_updates_last_used_at(self, client, admin_headers, regular_headers, regular_user): + enable_ha(client, admin_headers) + cred = create_credential(client, regular_headers) + assert cred["last_used_at"] is None + + client.get( + f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats", + headers={"Authorization": f"Bearer {cred['secret']}"}, + ) + listed = client.get("/webhooks", headers=regular_headers).json()[0] + assert listed["last_used_at"] is not None + + def test_rate_limit_trips_after_repeated_requests(self, client, admin_headers, regular_headers, regular_user): + enable_ha(client, admin_headers) + cred = create_credential(client, regular_headers) + + statuses = [ + client.get( + f"/webhook/{regular_user.username}/{cred['webhook_id']}/stats", + headers={"Authorization": f"Bearer {cred['secret']}"}, + ).status_code + for _ in range(35) + ] + assert 429 in statuses From 3816ea771f8baca99e6838d97c96ffc57d0c56df Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:42:33 -0400 Subject: [PATCH 13/22] Added test coverage for the webhook push service --- backend/tests/test_webhooks.py | 194 +++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 backend/tests/test_webhooks.py diff --git a/backend/tests/test_webhooks.py b/backend/tests/test_webhooks.py new file mode 100644 index 0000000..03c59fe --- /dev/null +++ b/backend/tests/test_webhooks.py @@ -0,0 +1,194 @@ +import hashlib +import secrets +import threading + +import pytest + +import models +from models.trade import Trade +import services.settings as settings_service +import services.webhooks as webhooks +from conftest import make_card, make_user + + +class FakeResponse: + status_code = 200 + + +class RecordingClient: + calls = [] + + def __init__(self, *a, **kw): + self._verify = kw.get("verify") + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def post(self, url, json=None): + RecordingClient.calls.append((url, json, self._verify)) + return FakeResponse() + + +class ImmediateThread: + def __init__(self, target=None, daemon=None): + self.target = target + + def start(self): + self.target() + + +@pytest.fixture(autouse=True) +def stub_transport(monkeypatch): + RecordingClient.calls = [] + monkeypatch.setattr(webhooks.threading, "Thread", ImmediateThread) + monkeypatch.setattr(webhooks.httpx, "Client", RecordingClient) + yield RecordingClient.calls + + +def enable_ha(db): + settings_service.set_value(db, "home_assistant_integration_enabled", "true") + + +def make_credential(db, user, target_url="https://ha.example.com/hook", enabled=True, label="Test", + verify_tls=True): + secret = secrets.token_urlsafe(16) + cred = models.WebhookCredential( + user_id=user.id, + label=label, + webhook_id=secrets.token_urlsafe(8), + secret_hash=hashlib.sha256(secret.encode()).hexdigest(), + target_url=target_url, + enabled=enabled, + verify_tls=verify_tls, + ) + db.add(cred) + db.commit() + db.refresh(cred) + return cred + + +class TestNotifyTradeEvent: + def test_pushes_to_both_participants(self, db, regular_user, stub_transport): + enable_ha(db) + other = make_user(db, username="other", email="other@example.com") + make_credential(db, regular_user, target_url="https://a.example.com/hook") + make_credential(db, other, target_url="https://b.example.com/hook") + + trade = Trade(initiator_id=regular_user.id, counterpart_id=other.id, status="proposed") + + webhooks.notify_trade_event(db, trade, "proposed", regular_user, other) + + urls = {c[0] for c in stub_transport} + assert urls == {"https://a.example.com/hook", "https://b.example.com/hook"} + for _, payload, _verify in stub_transport: + assert payload["event"] == "trade_proposed" + assert "timestamp" in payload + assert payload["initiator"] == regular_user.username + assert payload["counterpart"] == other.username + + def test_skips_participant_without_credentials(self, db, regular_user, stub_transport): + enable_ha(db) + other = make_user(db, username="other", email="other@example.com") + make_credential(db, regular_user, target_url="https://a.example.com/hook") + + trade = Trade(initiator_id=regular_user.id, counterpart_id=other.id, status="proposed") + webhooks.notify_trade_event(db, trade, "proposed", regular_user, other) + + assert len(stub_transport) == 1 + assert stub_transport[0][0] == "https://a.example.com/hook" + + def test_skips_disabled_credential(self, db, regular_user, stub_transport): + enable_ha(db) + other = make_user(db, username="other", email="other@example.com") + make_credential(db, regular_user, target_url="https://a.example.com/hook", enabled=False) + + trade = Trade(initiator_id=regular_user.id, counterpart_id=other.id, status="proposed") + webhooks.notify_trade_event(db, trade, "proposed", regular_user, other) + + assert stub_transport == [] + + def test_skips_credential_without_target_url(self, db, regular_user, stub_transport): + enable_ha(db) + other = make_user(db, username="other", email="other@example.com") + make_credential(db, regular_user, target_url=None) + + trade = Trade(initiator_id=regular_user.id, counterpart_id=other.id, status="proposed") + webhooks.notify_trade_event(db, trade, "proposed", regular_user, other) + + assert stub_transport == [] + + def test_no_op_when_integration_disabled(self, db, regular_user, stub_transport): + other = make_user(db, username="other", email="other@example.com") + make_credential(db, regular_user, target_url="https://a.example.com/hook") + + trade = Trade(initiator_id=regular_user.id, counterpart_id=other.id, status="proposed") + webhooks.notify_trade_event(db, trade, "proposed", regular_user, other) + + assert stub_transport == [] + + +class TestNotifyWishlistTargetMet: + def test_pushes_only_to_owner(self, db, regular_user, stub_transport): + enable_ha(db) + other = make_user(db, username="other", email="other@example.com") + make_credential(db, regular_user, target_url="https://owner.example.com/hook") + make_credential(db, other, target_url="https://other.example.com/hook") + + card = make_card(db) + entry = models.WishlistEntry(user_id=regular_user.id, card_id=card.id, target_price=1.00, foil=False) + db.add(entry) + db.commit() + db.refresh(entry) + + webhooks.notify_wishlist_target_met(db, entry, 0.75, "USD") + + assert len(stub_transport) == 1 + url, payload, _verify = stub_transport[0] + assert url == "https://owner.example.com/hook" + assert payload["event"] == "wishlist_target_met" + assert payload["card_name"] == card.name + assert payload["target_price"] == 1.00 + assert payload["current_price"] == 0.75 + assert payload["currency"] == "USD" + + +class TestNotifyTestEvent: + def test_sends_to_target_url(self, db, regular_user, stub_transport): + cred = make_credential(db, regular_user, target_url="https://a.example.com/hook") + webhooks.notify_test_event(cred) + assert len(stub_transport) == 1 + assert stub_transport[0][0] == "https://a.example.com/hook" + assert stub_transport[0][1]["event"] == "test" + + def test_no_op_without_target_url(self, db, regular_user, stub_transport): + cred = make_credential(db, regular_user, target_url=None) + webhooks.notify_test_event(cred) + assert stub_transport == [] + + +class TestVerifyTls: + def test_defaults_to_verifying(self, db, regular_user, stub_transport): + cred = make_credential(db, regular_user, target_url="https://a.example.com/hook") + webhooks.notify_test_event(cred) + assert stub_transport[0][2] is True + + def test_disabled_for_self_signed_credential(self, db, regular_user, stub_transport): + cred = make_credential(db, regular_user, target_url="https://a.example.com/hook", verify_tls=False) + webhooks.notify_test_event(cred) + assert stub_transport[0][2] is False + + def test_trade_event_respects_per_credential_flag(self, db, regular_user, stub_transport): + enable_ha(db) + other = make_user(db, username="other", email="other@example.com") + make_credential(db, regular_user, target_url="https://a.example.com/hook", verify_tls=False) + make_credential(db, other, target_url="https://b.example.com/hook", verify_tls=True) + + trade = Trade(initiator_id=regular_user.id, counterpart_id=other.id, status="proposed") + webhooks.notify_trade_event(db, trade, "proposed", regular_user, other) + + verify_by_url = {c[0]: c[2] for c in stub_transport} + assert verify_by_url["https://a.example.com/hook"] is False + assert verify_by_url["https://b.example.com/hook"] is True From d8e43dcd863268dc2571ae88f55d31c5c4af6604 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:48:24 -0400 Subject: [PATCH 14/22] Added test coverage for wishlist `target-price` webhook checks --- .../test_price_refresh_wishlist_targets.py | 104 ++++++++++++++++++ backend/tests/test_wishlist.py | 58 ++++++++++ 2 files changed, 162 insertions(+) create mode 100644 backend/tests/test_price_refresh_wishlist_targets.py diff --git a/backend/tests/test_price_refresh_wishlist_targets.py b/backend/tests/test_price_refresh_wishlist_targets.py new file mode 100644 index 0000000..6f999b9 --- /dev/null +++ b/backend/tests/test_price_refresh_wishlist_targets.py @@ -0,0 +1,104 @@ +from unittest.mock import patch +import models +from services.price_refresh import _check_wishlist_targets +from conftest import make_card + + +def make_wishlist_entry(db, user, card, target_price, foil=False, notified=False): + entry = models.WishlistEntry( + user_id=user.id, + card_id=card.id, + target_price=target_price, + foil=foil, + notified=notified, + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +class TestCheckWishlistTargets: + def test_fires_once_when_price_drops_to_target(self, db, regular_user): + card = make_card(db, price_usd=1.00, price_usd_foil=2.00) + entry = make_wishlist_entry(db, regular_user, card, target_price=1.00) + + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + assert mock_notify.call_count == 1 + db.refresh(entry) + assert entry.notified is True + + def test_does_not_refire_on_subsequent_check(self, db, regular_user): + card = make_card(db, price_usd=1.00) + make_wishlist_entry(db, regular_user, card, target_price=1.00) + + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + _check_wishlist_targets(db) + assert mock_notify.call_count == 1 + + def test_no_fire_when_price_above_target(self, db, regular_user): + card = make_card(db, price_usd=5.00) + make_wishlist_entry(db, regular_user, card, target_price=1.00) + + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + assert mock_notify.call_count == 0 + + def test_rearms_when_price_rises_back_above_target(self, db, regular_user): + card = make_card(db, price_usd=1.00) + entry = make_wishlist_entry(db, regular_user, card, target_price=1.00, notified=True) + + card.price_usd = 5.00 + db.commit() + + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + assert mock_notify.call_count == 0 + db.refresh(entry) + assert entry.notified is False + + def test_fires_again_after_rearm_on_next_drop(self, db, regular_user): + card = make_card(db, price_usd=5.00) + entry = make_wishlist_entry(db, regular_user, card, target_price=1.00, notified=False) + + card.price_usd = 0.50 + db.commit() + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + assert mock_notify.call_count == 1 + db.refresh(entry) + assert entry.notified is True + + def test_uses_foil_price_for_foil_entries(self, db, regular_user): + card = make_card(db, price_usd=10.00, price_usd_foil=1.00) + make_wishlist_entry(db, regular_user, card, target_price=2.00, foil=True) + + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + assert mock_notify.call_count == 1 + call_kwargs = mock_notify.call_args[0] + assert call_kwargs[2] == 1.00 + + def test_ignores_entries_without_target_price(self, db, regular_user): + card = make_card(db, price_usd=0.01) + make_wishlist_entry(db, regular_user, card, target_price=None) + + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + assert mock_notify.call_count == 0 + + def test_converts_price_for_non_native_preferred_currency(self, db, regular_user): + regular_user.preferred_currency = "gbp" + db.add(models.ConvertedCurrency(code="GBP", symbol="£", rate=0.5)) + db.commit() + + card = make_card(db, price_usd=10.00) + make_wishlist_entry(db, regular_user, card, target_price=6.00) + + with patch("services.webhooks.notify_wishlist_target_met") as mock_notify: + _check_wishlist_targets(db) + assert mock_notify.call_count == 1 + current_price = mock_notify.call_args[0][2] + assert current_price == 5.00 diff --git a/backend/tests/test_wishlist.py b/backend/tests/test_wishlist.py index 11e334f..f411ae7 100644 --- a/backend/tests/test_wishlist.py +++ b/backend/tests/test_wishlist.py @@ -255,6 +255,64 @@ def test_cannot_update_another_users_entry(self, client, db, regular_user, admin assert r.status_code == 404 +class TestNotifiedResetOnUpdate: + def _mark_notified(self, db, entry): + entry.notified = True + db.commit() + db.refresh(entry) + + def test_changing_target_price_resets_notified(self, client, db, regular_user): + card = make_card(db) + entry = make_wishlist_entry(db, regular_user, card, target_price=5.00) + self._mark_notified(db, entry) + + client.patch(f"/wishlist/{entry.id}", json={"target_price": 2.50}, headers=auth_headers(regular_user)) + db.refresh(entry) + assert entry.notified is False + + def test_setting_same_target_price_does_not_reset_notified(self, client, db, regular_user): + card = make_card(db) + entry = make_wishlist_entry(db, regular_user, card, target_price=5.00) + self._mark_notified(db, entry) + + client.patch(f"/wishlist/{entry.id}", json={"target_price": 5.00}, headers=auth_headers(regular_user)) + db.refresh(entry) + assert entry.notified is True + + def test_changing_foil_resets_notified(self, client, db, regular_user): + card = make_card(db) + entry = make_wishlist_entry(db, regular_user, card, target_price=5.00, foil=False) + self._mark_notified(db, entry) + + client.patch(f"/wishlist/{entry.id}", json={"foil": True}, headers=auth_headers(regular_user)) + db.refresh(entry) + assert entry.notified is False + + def test_changing_card_resets_notified(self, client, db, regular_user): + card_a = make_card(db, scryfall_id="a-1", name="Card A") + card_b = make_card(db, scryfall_id="b-1", name="Card B") + entry = make_wishlist_entry(db, regular_user, card_a, target_price=5.00) + self._mark_notified(db, entry) + + with patch("services.scryfall.get_card_by_scryfall_id", return_value=card_b): + client.patch( + f"/wishlist/{entry.id}", + json={"scryfall_id": card_b.scryfall_id}, + headers=auth_headers(regular_user), + ) + db.refresh(entry) + assert entry.notified is False + + def test_updating_notes_only_does_not_reset_notified(self, client, db, regular_user): + card = make_card(db) + entry = make_wishlist_entry(db, regular_user, card, target_price=5.00) + self._mark_notified(db, entry) + + client.patch(f"/wishlist/{entry.id}", json={"notes": "still watching"}, headers=auth_headers(regular_user)) + db.refresh(entry) + assert entry.notified is True + + # DELETE /wishlist/{entry_id} class TestRemoveFromWishlist: From b91863663c26792928fdb18d9e595fb7fc7f2ead Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:49:31 -0400 Subject: [PATCH 15/22] Added Webhooks page for managing credentials --- frontend/src/pages/Webhooks.jsx | 327 ++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 frontend/src/pages/Webhooks.jsx diff --git a/frontend/src/pages/Webhooks.jsx b/frontend/src/pages/Webhooks.jsx new file mode 100644 index 0000000..ec36bfe --- /dev/null +++ b/frontend/src/pages/Webhooks.jsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Plus, Trash2, RefreshCw, Send, Copy, Check, X, ShieldOff, ShieldCheck } from 'lucide-react' +import { useAuth } from '../hooks/useAuth' +import { useIsMobile } from '../hooks/useIsMobile' +import api from '../api' +import ConfirmModal from '../components/ConfirmModal' + +function NewCredentialModal({ onClose, onCreated }) { + const [label, setLabel] = useState('') + const [targetUrl, setTargetUrl] = useState('') + const [skipVerify, setSkipVerify] = useState(false) + const [error, setError] = useState('') + + const create = useMutation({ + mutationFn: () => api.post('/webhooks', { + label: label.trim(), + target_url: targetUrl.trim() || null, + verify_tls: !skipVerify, + }), + onSuccess: (res) => onCreated(res.data), + onError: (err) => setError(err.response?.data?.detail || 'Failed to create credential'), + }) + + return ( +
+
e.stopPropagation()}> +

New Webhook Credential

+ {error &&
{error}
} +
+ + setLabel(e.target.value)} placeholder="Living Room HA" /> +
+
+ + setTargetUrl(e.target.value)} + placeholder="https://homeassistant.local:8123/api/webhook/xxxx" /> +
+ Trade and wishlist alerts push here. Leave blank for a pull-only credential used just to + fetch stats. +
+
+
+ +
+ Only enable this for a target URL with a self-signed certificate or an internal CA on + your own trusted network (e.g. Home Assistant reachable at https://homeassistant.local). Leave off + for any URL with a normal publicly-trusted certificate. +
+
+
+ + +
+
+
+ ) +} + +function SecretRevealModal({ credential, onClose }) { + const [copiedField, setCopiedField] = useState(null) + const fullUrl = `${window.location.origin}${credential.inbound_url}` + + const copy = async (field, text) => { + try { + await navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(f => (f === field ? null : f)), 1500) + } catch { /* clipboard unavailable */ } + } + + return ( +
+
e.stopPropagation()}> +

Webhook Credential Created

+
+ The secret below is shown once and cannot be retrieved again. Save it in Home Assistant now. +
+
+ +
+ {fullUrl} + +
+
+ Configure a Home Assistant RESTful sensor against this URL with header + Authorization: Bearer <secret>. +
+
+
+ +
+ {credential.secret} + +
+
+
+ +
+
+
+ ) +} + +function CredentialActions({ credential, onToggle, onToggleVerify, onRegenerate, onTest, onDelete, testMessage }) { + return ( + <> +
+ {credential.target_url && ( + <> + + + + )} + + + +
+ {testMessage &&
{testMessage}
} + + ) +} + +export default function Webhooks() { + useEffect(() => { document.title = 'Webhooks - OpenMTG' }, []) + + const { homeAssistantEnabled } = useAuth() + const isMobile = useIsMobile() + const qc = useQueryClient() + const [showCreate, setShowCreate] = useState(false) + const [revealCredential, setRevealCredential] = useState(null) + const [confirmAction, setConfirmAction] = useState(null) + const [testMessages, setTestMessages] = useState({}) + + const { data: credentials = [], isLoading } = useQuery({ + queryKey: ['webhook-credentials'], + queryFn: () => api.get('/webhooks').then(r => r.data), + enabled: homeAssistantEnabled, + }) + + const invalidate = () => qc.invalidateQueries(['webhook-credentials']) + + const toggleEnabled = useMutation({ + mutationFn: ({ id, enabled }) => api.patch(`/webhooks/${id}`, { enabled }), + onSuccess: invalidate, + }) + + const toggleVerifyTls = useMutation({ + mutationFn: ({ id, verify_tls }) => api.patch(`/webhooks/${id}`, { verify_tls }), + onSuccess: invalidate, + }) + + const deleteCredential = useMutation({ + mutationFn: (id) => api.delete(`/webhooks/${id}`), + onSuccess: invalidate, + }) + + const regenerate = useMutation({ + mutationFn: (id) => api.post(`/webhooks/${id}/regenerate-secret`), + onSuccess: (res) => { invalidate(); setRevealCredential(res.data) }, + }) + + const testPush = useMutation({ + mutationFn: (id) => api.post(`/webhooks/${id}/test`), + onSuccess: (_res, id) => setTestMessages(m => ({ ...m, [id]: 'Sent!' })), + onError: (err, id) => setTestMessages(m => ({ ...m, [id]: err.response?.data?.detail || 'Failed to send' })), + }) + + const confirmDelete = (c) => setConfirmAction({ + message: `Delete "${c.label}"? Any Home Assistant automation using it will stop receiving events.`, + onConfirm: () => { deleteCredential.mutate(c.id); setConfirmAction(null) }, + }) + + if (!homeAssistantEnabled) { + return ( +
+

Home Assistant integration is currently disabled.

+

Ask an admin to enable it from Settings.

+
+ ) + } + + return ( +
+
+
+

Webhooks

+
+ {credentials.length} credential{credentials.length !== 1 ? 's' : ''} +
+
+ +
+ + {isLoading &&
Loading
} + + {!isLoading && credentials.length === 0 && ( +
+

No webhook credentials yet.

+

Create one to push trade and wishlist alerts to Home Assistant, or pull live collection stats.

+
+ )} + + {credentials.length > 0 && (isMobile ? ( +
+ {credentials.map(c => ( +
+
+ {c.label} + {c.enabled + ? Enabled + : Disabled} +
+
+ {c.target_url || 'Pull-only'} + {c.target_url && !c.verify_tls && Insecure} +
+
+ Last used: {c.last_used_at ? new Date(c.last_used_at).toLocaleString() : 'Never'} +
+
+ toggleEnabled.mutate({ id: c.id, enabled: !c.enabled })} + onToggleVerify={() => toggleVerifyTls.mutate({ id: c.id, verify_tls: !c.verify_tls })} + onRegenerate={() => regenerate.mutate(c.id)} + onTest={() => testPush.mutate(c.id)} + onDelete={() => confirmDelete(c)} + testMessage={testMessages[c.id]} + /> +
+
+ ))} +
+ ) : ( + + + + + + + + + + + + {credentials.map(c => ( + + + + + + + + ))} + +
LabelTarget URLLast UsedStatus
{c.label} + {c.target_url || 'Pull-only'} + {c.target_url && !c.verify_tls && Insecure} + + {c.last_used_at ? new Date(c.last_used_at).toLocaleString() : 'Never'} + + {c.enabled + ? Enabled + : Disabled} + + toggleEnabled.mutate({ id: c.id, enabled: !c.enabled })} + onToggleVerify={() => toggleVerifyTls.mutate({ id: c.id, verify_tls: !c.verify_tls })} + onRegenerate={() => regenerate.mutate(c.id)} + onTest={() => testPush.mutate(c.id)} + onDelete={() => confirmDelete(c)} + testMessage={testMessages[c.id]} + /> +
+ ))} + + {showCreate && ( + setShowCreate(false)} + onCreated={(cred) => { setShowCreate(false); invalidate(); setRevealCredential(cred) }} + /> + )} + {revealCredential && ( + setRevealCredential(null)} /> + )} + {confirmAction && ( + setConfirmAction(null)} + /> + )} +
+ ) +} From 043cc5df7fe4ea36a676bf56cca5ee1212e8af56 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:50:36 -0400 Subject: [PATCH 16/22] Added Webhooks nav link and route --- frontend/src/App.jsx | 2 ++ frontend/src/AuthContext.jsx | 8 +++++++- frontend/src/components/Layout.jsx | 12 +++++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9aa873b..b002d4a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,7 @@ import ShowroomEdit from './pages/ShowroomEdit' import ShowroomDeckView from './pages/ShowroomDeckView' import Trades from './pages/Trades' import TradeDetail from './pages/TradeDetail' +import Webhooks from './pages/Webhooks' function PrivateRoute({ children }) { const { user, loading, setupRequired } = useAuth() @@ -57,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> ) diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx index 7153ca9..ccaa2df 100644 --- a/frontend/src/AuthContext.jsx +++ b/frontend/src/AuthContext.jsx @@ -11,6 +11,7 @@ export function AuthProvider({ children }) { const [showroomEnabled, setShowroomEnabled] = useState(true) const [scannerEnabled, setScannerEnabled] = useState(true) const [tradesEnabled, setTradesEnabled] = useState(true) + const [homeAssistantEnabled, setHomeAssistantEnabled] = useState(false) useEffect(() => { const init = async () => { @@ -41,6 +42,11 @@ export function AuthProvider({ children }) { setTradesEnabled(feat.data.enabled) } catch { /* non-fatal */ } + try { + const feat = await api.get('/webhooks/status') + setHomeAssistantEnabled(feat.data.enabled) + } catch { /* non-fatal */ } + const token = localStorage.getItem('token') if (!token) { setLoading(false) @@ -86,7 +92,7 @@ export function AuthProvider({ children }) { } return ( - + {children} ) diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 175d040..e4938fd 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Book, Layers, Search, BarChart2, UserCog, Settings, Star, Menu, LogOut, Eye, ArrowLeftRight } from 'lucide-react' +import { Book, Layers, Search, BarChart2, UserCog, Settings, Star, Menu, LogOut, Eye, ArrowLeftRight, Webhook } from 'lucide-react' import { Outlet, NavLink } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { useAuth } from '../hooks/useAuth' @@ -12,8 +12,9 @@ const BASE_NAV_LINKS = [ { to: '/wishlist', icon: Star, label: 'Wishlist' }, { to: '/stats', icon: BarChart2, label: 'Stats' }, ] -const SCANNER_LINK = { to: '/card-search', icon: Search, label: 'Card Search' } -const TRADES_LINK = { to: '/trades', icon: ArrowLeftRight, label: 'Trades', pendingKey: true } +const SCANNER_LINK = { to: '/card-search', icon: Search, label: 'Card Search' } +const TRADES_LINK = { to: '/trades', icon: ArrowLeftRight, label: 'Trades', pendingKey: true } +const WEBHOOKS_LINK = { to: '/webhooks', icon: Webhook, label: 'Webhooks' } const ADMIN_LINKS = [ { to: '/admin', icon: UserCog, label: 'Admin' }, @@ -33,7 +34,7 @@ function NavItem({ to, icon: Icon, label, hasPending, onClick }) { } export default function Layout() { - const { user, logout, showroomEnabled, scannerEnabled, tradesEnabled } = useAuth() + const { user, logout, showroomEnabled, scannerEnabled, tradesEnabled, homeAssistantEnabled } = useAuth() const showroomLink = { to: `/showroom/edit/${user?.username?.toLowerCase()}`, icon: Eye, label: 'Showroom' } const isMobile = useIsMobile() const [menuOpen, setMenuOpen] = useState(false) @@ -50,7 +51,8 @@ export default function Layout() { ? [...BASE_NAV_LINKS.slice(0, 3), SCANNER_LINK, ...BASE_NAV_LINKS.slice(3)] : BASE_NAV_LINKS const withTrades = tradesEnabled ? [...navLinks, TRADES_LINK] : navLinks - const baseLinks = showroomEnabled ? [...withTrades, showroomLink] : withTrades + const withWebhooks = homeAssistantEnabled ? [...withTrades, WEBHOOKS_LINK] : withTrades + const baseLinks = showroomEnabled ? [...withWebhooks, showroomLink] : withWebhooks const links = user?.is_admin ? [...baseLinks, ...ADMIN_LINKS] : baseLinks const close = () => setMenuOpen(false) From e4f35c5d7422d26a3069ad2ef449b7cf11309fa5 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:51:03 -0400 Subject: [PATCH 17/22] Added webhook credential list and secret-reveal modal status --- frontend/src/index.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/index.css b/frontend/src/index.css index 638f04e..1676cd7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -410,6 +410,16 @@ padding: 0.25rem 0.5rem; font-size: 0.85rem; cursor: pointer; } .currencies-empty { font-size: 0.875rem; color: var(--text-muted); } .currency-rate-cell { font-family: monospace; font-size: 0.875rem; } +/* Webhooks */ +.webhook-list { display: flex; flex-direction: column; } +.webhook-card { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border); } +.webhook-card-info { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; } +.webhook-card-name { font-weight: 600; } +.webhook-card-actions { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; } +.secret-row { display: flex; align-items: center; gap: 0.5rem; } +.secret-value { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); +padding: 0.5rem 0.6rem; font-size: 0.8rem; word-break: break-all; } + /* Collection */ .filter-bar { display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 0; align-items: center; } .filter-group { display: flex; align-items: center; gap: 8px; } From 0ec4ba74922f0f4e2263d48ad509fe8ab2ab9b95 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:51:33 -0400 Subject: [PATCH 18/22] Added Home Assistant Integration toggle to Settings page --- frontend/src/pages/Settings.jsx | 54 ++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index a15e78b..2c5f34d 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -223,7 +223,10 @@ export default function Settings() { if (pollFast && status?.stale_cards === 0) setPollFast(false); }, [status?.stale_cards, pollFast]); - const [form, setForm] = useState({ price_refresh_hours: 72, showroom_enabled: true, card_search_enabled: true, trades_enabled: true }); + const [form, setForm] = useState({ + price_refresh_hours: 72, showroom_enabled: true, card_search_enabled: true, trades_enabled: true, + home_assistant_integration_enabled: false, home_assistant_max_credentials_per_user: 3, + }); const [refreshing, setRefreshing] = useState(false); const [refreshMsg, setRefreshMsg] = useState(""); @@ -236,6 +239,8 @@ export default function Settings() { showroom_enabled: currentSettings.showroom_enabled !== 'false', card_search_enabled: currentSettings.card_search_enabled !== 'false', trades_enabled: currentSettings.trades_enabled !== 'false', + home_assistant_integration_enabled: currentSettings.home_assistant_integration_enabled === 'true', + home_assistant_max_credentials_per_user: parseInt(currentSettings.home_assistant_max_credentials_per_user) || 3, }); } }, [currentSettings]); @@ -512,6 +517,53 @@ export default function Settings() { + +
+
+ +
+
+
+ {form.home_assistant_integration_enabled ? 'Home Assistant Integration is enabled' : 'Home Assistant Integration is disabled'} +
+
+ When enabled, every user gets a Webhooks page to generate their own credentials for + pushing trade and wishlist alerts to Home Assistant, and pulling live collection stats. +
+ {form.home_assistant_integration_enabled && ( +
+ + setForm(f => ({ ...f, home_assistant_max_credentials_per_user: parseInt(e.target.value) || 1 }))} + onBlur={() => save.mutate({ home_assistant_max_credentials_per_user: form.home_assistant_max_credentials_per_user })} + /> +
+ )} +
+
)} From 378bb34b420c8792afcb0cbb6bc90200b86bca55 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:52:14 -0400 Subject: [PATCH 19/22] Updated application versioning to 1.10.0 --- backend/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/constants.py b/backend/constants.py index 76d1400..3c47a01 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -8,8 +8,8 @@ VERSIONS = { - "API_VERSION": "1.9.2", - "APPLICATION_VERSION": "1.9.2", + "API_VERSION": "1.10.0", + "APPLICATION_VERSION": "1.10.0", } API_RATES = { From dedab978e280b01194b590a40903a8b2242447a9 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:52:31 -0400 Subject: [PATCH 20/22] Updated CHANGELOG for v1.10.0 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 430c2f9..7afd357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v1.10.0 + +### Added + +- **Home Assistant Integration** - Per-user webhook credentials for pushing trade and wishlist-price alerts to Home Assistant, and pulling live collection stats. + - New Webhooks page where any user can generate credentials, each pairing a Home Assistant target URL with a bearer secret shown once at creation. + - Push events for trade proposals, updates, engagement, acceptance, and rejection/cancellation, plus a wishlist target-price-met alert. + - A new authenticated pull endpoint for Home Assistant to fetch a user's collection stats on its own schedule. + - Per-credential TLS verification toggle for Home Assistant instances behind a self-signed certificate or internal CA, with a visible "Insecure" indicator when disabled. + - Admin controls in Settings to enable/disable the integration and cap credentials per user (default 3, off by default). + - See the wiki for setup and Home Assistant configuration details. + +### Changed + +- Version bumped from 1.9.2 to 1.10.0 within `constants.py`. + ## v1.9.2 ### Fixed From 48bc3f840bd868df14eb1ffaf0ade91b6e74a38d Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 14:52:47 -0400 Subject: [PATCH 21/22] Updated README for Home Assistant Integration --- README.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bc55708..44259e8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ![Arch](https://img.shields.io/badge/arch-AMD64%20%7C%20ARM64-informational) ![Scryfall](https://img.shields.io/badge/data-Scryfall-E35B2A) -Self-hosted MTG card inventory server with multi-account support, collection tracking, deck building, statistics, wishlist, public Showroom display, card trading, loan tracking, card photos, and import/export. Built with FastAPI and React, deployed with Docker. +Self-hosted MTG card inventory server with multi-account support, collection tracking, deck building, statistics, wishlist, public Showroom display, card trading, loan tracking, card photos, Home Assistant integration, and import/export. Built with FastAPI and React, deployed with Docker. --- @@ -40,6 +40,7 @@ Self-hosted MTG card inventory server with multi-account support, collection tra - **Card Trading** - Propose and complete card trades with other users on the same instance. Both sides confirm before cards auto-transfer between collections. Trade history stored separately. - **Loan Tracking** - Mark cards in your collection as on loan with a recipient name and date. Loaned cards display a badge in the Collection view. - **Card Photos** - Upload front and back photos for individual cards in your collection. Photos are viewable by trade counterparts when reviewing a trade offer. +- **Home Assistant Integration** - Per-user webhook credentials push trade and wishlist price-target alerts to Home Assistant, and a bearer-authenticated pull endpoint serves live collection stats to a Home Assistant sensor. Admin-gated and off by default. --- @@ -51,17 +52,8 @@ For those moving up from versions previous to v1.9.0, there are extra steps invo ## Roadmap -### Release Plan - -- **v1.10** - Home Assistant Integration: webhooks for custom dashboards, price alerts, and watchlist notifications - -### Short-term - - **Set Completion** - Appending the statistics page to include per-set completion for the collectors. - **Expand Import/Export** - Expand accepted import formats beyond Moxfield/MTGO/Arena, and add collection export format options. - -### Long-term - - **Bulk Data Download** - Scryfall allows for users to download the full catalog of card information. An option is planned to allow users to download the entire database at once for faster card lookups. ### Not Planned @@ -77,7 +69,7 @@ For those moving up from versions previous to v1.9.0, there are extra steps invo | Layer | Technology | |---|---| | Application | Python 3.14, FastAPI, SQLAlchemy, Alembic, React, Vite, TanStack Query | -| Database | PostgreSQL 16 | +| Database | PostgreSQL 16 / SQLite | | Reverse Proxy | Nginx | | Container | Docker + Docker Compose | @@ -166,11 +158,14 @@ All configuration is done via the `.env` file or the admin **Settings** panel in | `DB_PASSWORD` | Database password | *(required)* | | `JWT_SECRET` | Secret key for auth tokens | *(required)* | | `DATA_PATH` | Path for PostgreSQL data volume | `./data` | +| `SQLITE_PATH` | Path for the SQLite database file, if using `docker-compose.sqlite.yml` instead of PostgreSQL | `./sqlite-data` | | `CONFIG_PATH` | Path for app config volume | `./config` | | `UPLOADS_PATH` | Path for card photo uploads | `./uploads` | | `TRADES_PATH` | Path for trade history database | `./trades` | | `NOTEL` | Option to disable telemetry settings | Not present by default | +> Database backend (PostgreSQL vs SQLite) is chosen by which compose file you run, not by an environment variable: `docker-compose.yml` uses `DATA_PATH`, `docker-compose.sqlite.yml` uses `SQLITE_PATH`. This is a permanent, per-instance choice locked in at first-time setup. See the [Install Guide](https://github.com/DredBaron/OpenMTG/wiki/Installation). + ### Price Refresh Settings (Admin UI) | Setting | Description | Default | @@ -185,12 +180,13 @@ All configuration is done via the `.env` file or the admin **Settings** panel in | Showroom | Enables the public Showroom display page, nav link, and per-card/per-deck visibility toggles | Enabled | | Card Search | Enables the Card Search page and nav link | Enabled | | Trades | Enables the Trades page, nav link, and trade proposal workflow between users | Enabled | +| Home Assistant Integration | Enables the Webhooks page and per-user webhook credentials for pushing trade/wishlist events to Home Assistant and pulling live stats | Disabled | --- ## Ports -By default, OpenMTG listens on port **8080**. To change it, edit the `nginx` service in `docker-compose.yml`: +By default, OpenMTG listens on port **8080**. To change it, edit the `app` service's port mapping in `docker-compose.yml`: ```yaml ports: From 6397abc1505c5977a7166abd4a75c5722fb8ed31 Mon Sep 17 00:00:00 2001 From: Todd Hahn Date: Sat, 12 Sep 2026 15:11:58 -0400 Subject: [PATCH 22/22] Migrated and updated Scryfall and Frankfurter accreditation --- CREDITS.md | 4 ++++ README.md | 12 ++---------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/CREDITS.md b/CREDITS.md index 9a70cfe..46d872f 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,3 +1,7 @@ ## Credits Favicon icon by [Faithtoken](https://game-icons.net/1x1/faithtoken/card-pick.html), licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). + +Card data, imagery, and pricing provided by [Scryfall](https://scryfall.com), used in accordance with their [API Terms of Service](https://scryfall.com/docs/api). Scryfall is not affiliated with or endorsed by Wizards of the Coast. + +Currency exchange rates provided by [Frankfurter](https://frankfurter.dev), a free, open-source exchange rate API sourced from the European Central Bank and other central banks. diff --git a/README.md b/README.md index 44259e8..4391bab 100644 --- a/README.md +++ b/README.md @@ -215,15 +215,9 @@ You are free to use, modify, and self-host OpenMTG. If you distribute a modified --- -## Acknowledgements - -Card data and pricing provided by [Scryfall](https://scryfall.com). Please respect their [API guidelines](https://scryfall.com/docs/api) and rate limits. - ---- - ## Development History -This project was initially conceived with AI reference (Claude by Anthropic) +This project was initially conceived with AI reference (Claude by Anthropic) as a learning exercise in building self-hosted MTG collection tools, as well as understanding Docker image development processes. Active development is now entirely human-driven. @@ -238,9 +232,7 @@ Contributions are welcome and reviewed by human maintainer(s) only. ## Credits -Favicon icon by [Faithtoken](https://game-icons.net/1x1/faithtoken/card-pick.html), licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). - -Card data, imagery, and pricing provided by [Scryfall](https://scryfall.com), used in accordance with their [API Terms of Service](https://scryfall.com/docs/api). Scryfall is not affiliated with or endorsed by Wizards of the Coast. +See [CREDITS.md](CREDITS.md) for third-party data, API, and asset attributions. ## Notes