From fb94da7cce998f09db62e01d08062dc7d8a2e803 Mon Sep 17 00:00:00 2001 From: Kayos Date: Fri, 1 May 2026 20:40:56 -0700 Subject: [PATCH] discover: per-household imports + group-aware UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-driven cleanup of the multi-household design: - migration 035: cauldron_households.mealie_group_id (was missing — blocked any cross-household reasoning) - migration 036: cauldron_recipe_index.mealie_group_id (additive; current per-household keying preserved, the column lets future code key by group without a destructive PK rebuild) - migration 037: drop dead cauldron_food_mapping (replaced by cauldron_food_metadata in foundation reset Step 2 / commit f74a627 2026-04-30; zero callers in the codebase) - migration 038: cauldron_discover_imports(discover_id, household_id, mealie_slug, imported_by_sub, imported_at) — per-household provenance. Replaces the global cauldron_discovered_recipes.status='imported' flag that incorrectly hid the import button from every user once anyone imported. Code: - sync_user_household reads who_am_i()["groupId"] (and nested group.id) and persists it on cauldron_households.mealie_group_id - upsert_household accepts mealie_group_id with COALESCE-on-update - new helpers: record_discover_import, get_discover_imports_for_group, discover_imported_by_household, get_household - /api/discover/import: per-household idempotency (returns cached slug if this household already imported), records to the new join table; no longer flips global status='imported' - /api/discover/search: decorates each row with imported_in_my_group + imported_by_my_household + mealie_slug + imported_by_household_name - discover.html: card render uses imported_in_my_group; shows "✓ in your library as " when this household imported, or "✓ shared from as " when another household in the group imported (recipe is group-shared via Mealie's group-scope read so re-importing would create a duplicate) The imported badge now correctly surfaces "imported by another household in your group" rather than hiding the row entirely. --- cauldron/db.py | 157 +++++++++++++++++++++++++++++-- cauldron/server.py | 68 ++++++++++++- cauldron/templates/discover.html | 26 ++++- 3 files changed, 235 insertions(+), 16 deletions(-) diff --git a/cauldron/db.py b/cauldron/db.py index fc3fe0d..123f818 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -554,6 +554,56 @@ MIGRATIONS = [ INDEX idx_state (state) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, + # 035 — Track the Mealie GROUP a household belongs to. Until now cauldron + # only stored mealie_household_id, but Mealie's recipe / food / unit / + # category / tag / tool tables are GROUP-scoped on read (cauldron audit + # 2026-05-01). To reason about "is this discover import already in my + # group's library" we need the group id. Backfilled at boot via + # sync_user_household reading who_am_i()["groupId"]. + """ + ALTER TABLE cauldron_households + ADD COLUMN IF NOT EXISTS mealie_group_id VARCHAR(64) NULL, + ADD INDEX IF NOT EXISTS idx_mealie_group_id (mealie_group_id) + """, + # 036 — Same column on cauldron_recipe_index. The index is per-household + # today (2x storage for a 2-household group) but the underlying recipes + # are group-shared. Adding this column doesn't change current write + # behavior; it lets future code de-duplicate the cache keyed by group. + """ + ALTER TABLE cauldron_recipe_index + ADD COLUMN IF NOT EXISTS mealie_group_id VARCHAR(64) NULL, + ADD INDEX IF NOT EXISTS idx_recipe_group_id (mealie_group_id) + """, + # 037 — Drop the dead cauldron_food_mapping table. Foundation-reset + # Step 2 (commit f74a627 2026-04-30) replaced it with cauldron_food_metadata + # which is keyed by Mealie's food UUID and is therefore already group- + # implicit. cauldron_food_mapping has zero callers in the codebase as of + # 2026-05-01. + """ + DROP TABLE IF EXISTS cauldron_food_mapping + """, + # 038 — Per-household provenance for /discover imports. Replaces the + # global `cauldron_discovered_recipes.status='imported'` flag, which + # incorrectly hid the import-button from every household once any user + # imported. The new model: a discover row stays at status='enriched' + # forever (or 'rejected' if explicitly skipped); imports are recorded + # per-household here. The /discover search endpoint joins this against + # the user's group's households so the UI can show "✓ already in your + # group's library" without forcing every household to re-import. + """ + CREATE TABLE IF NOT EXISTS cauldron_discover_imports ( + discover_id BIGINT NOT NULL, + household_id BIGINT NOT NULL, + mealie_slug VARCHAR(255) NOT NULL, + imported_by_sub VARCHAR(190), + imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (discover_id, household_id), + INDEX idx_household (household_id), + INDEX idx_slug (mealie_slug), + FOREIGN KEY (discover_id) REFERENCES cauldron_discovered_recipes(id) ON DELETE CASCADE, + FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, ] @@ -690,16 +740,28 @@ class DB: # --- households --------------------------------------------------------- - def upsert_household(self, *, mealie_household_id: str, name: str) -> int: - """Create or update a household record. Returns local PK (id).""" + def upsert_household( + self, + *, + mealie_household_id: str, + name: str, + mealie_group_id: str | None = None, + ) -> int: + """Create or update a household record. Returns local PK (id). + `mealie_group_id` is optional for back-compat with older callers but + should be supplied by sync_user_household so cross-household reasoning + works.""" with self.conn() as c, c.cursor() as cur: cur.execute( """ - INSERT INTO cauldron_households (mealie_household_id, name) - VALUES (%s, %s) - ON DUPLICATE KEY UPDATE name = VALUES(name), id = LAST_INSERT_ID(id) + INSERT INTO cauldron_households (mealie_household_id, name, mealie_group_id) + VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + mealie_group_id = COALESCE(VALUES(mealie_group_id), mealie_group_id), + id = LAST_INSERT_ID(id) """, - (mealie_household_id, name), + (mealie_household_id, name, mealie_group_id), ) return cur.lastrowid @@ -723,6 +785,16 @@ class DB: row = cur.fetchone() return row["household_id"] if row else None + def get_household(self, household_id: int) -> dict | None: + """Return a household row by local id, including mealie_group_id.""" + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT id, mealie_household_id, mealie_group_id, name, created_at + FROM cauldron_households WHERE id=%s""", + (household_id,), + ) + return cur.fetchone() + def list_household_member_subs(self, household_id: int) -> list[str]: with self.conn() as c, c.cursor() as cur: cur.execute( @@ -2186,7 +2258,10 @@ class DB: ) def set_discovered_status(self, discover_id: int, status: str) -> None: - """Move a discovered recipe to 'imported' or 'rejected'.""" + """Move a discovered recipe's GLOBAL status. Use for 'rejected' only; + per-household 'imported' is now tracked in cauldron_discover_imports + (see record_discover_import). Kept for back-compat — the column + stays for ENUM compatibility.""" with self.conn() as c, c.cursor() as cur: cur.execute( """UPDATE cauldron_discovered_recipes @@ -2195,6 +2270,74 @@ class DB: (status, discover_id), ) + def record_discover_import( + self, + *, + discover_id: int, + household_id: int, + mealie_slug: str, + imported_by_sub: str | None, + ) -> None: + """Record that a household has imported a discovered recipe into its + Mealie library. INSERT IGNORE so a duplicate POST is a no-op rather + than a 409 — the user can click import twice and we just keep the + first slug we recorded. Composite PK (discover_id, household_id).""" + with self.conn() as c, c.cursor() as cur: + cur.execute( + """INSERT IGNORE INTO cauldron_discover_imports + (discover_id, household_id, mealie_slug, imported_by_sub) + VALUES (%s, %s, %s, %s)""", + (discover_id, household_id, mealie_slug[:255], imported_by_sub), + ) + + def get_discover_imports_for_group( + self, *, mealie_group_id: str + ) -> dict[int, dict]: + """Return {discover_id: {household_id, household_name, mealie_slug, + imported_at}} for every import by ANY household in the given Mealie + group. The /discover search endpoint uses this to mark cards as + 'already in your group's library' since Mealie's group-scope read + means a recipe imported by household A is visible to household B.""" + if not mealie_group_id: + return {} + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT i.discover_id, i.household_id, h.name AS household_name, + h.mealie_household_id, i.mealie_slug, i.imported_at + FROM cauldron_discover_imports i + JOIN cauldron_households h ON h.id = i.household_id + WHERE h.mealie_group_id = %s""", + (mealie_group_id,), + ) + out: dict[int, dict] = {} + for r in cur.fetchall() or []: + did = r["discover_id"] + row = dict(r) + if hasattr(row.get("imported_at"), "isoformat"): + row["imported_at"] = row["imported_at"].isoformat() + # First-import-wins per discover_id (oldest reaches us first + # by PK order, but we don't sort — preserve insertion order) + out.setdefault(did, row) + return out + + def discover_imported_by_household( + self, *, discover_id: int, household_id: int + ) -> dict | None: + """Look up the import record for one (discover, household) pair. + Used by /api/discover/import to detect re-imports.""" + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT discover_id, household_id, mealie_slug, + imported_by_sub, imported_at + FROM cauldron_discover_imports + WHERE discover_id=%s AND household_id=%s""", + (discover_id, household_id), + ) + row = cur.fetchone() + if row and hasattr(row.get("imported_at"), "isoformat"): + row["imported_at"] = row["imported_at"].isoformat() + return row + def get_discovered_recipe(self, discover_id: int) -> dict | None: with self.conn() as c, c.cursor() as cur: cur.execute( diff --git a/cauldron/server.py b/cauldron/server.py index 6b9239a..91cb305 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -217,7 +217,22 @@ def create_app() -> Flask: if not h_id_mealie: return None - local_id = db.upsert_household(mealie_household_id=str(h_id_mealie), name=str(h_name)) + # Mealie's user-self also exposes the parent group id. Capture both + # shapes (top-level groupId AND nested group object) so we can reason + # about cross-household visibility within the same group. + g_id_mealie: str | None = None + g = me.get("group") + if isinstance(g, dict): + g_id_mealie = g.get("id") or g.get("slug") + elif isinstance(g, str) and g: + g_id_mealie = g + g_id_mealie = g_id_mealie or me.get("groupId") or me.get("group_id") or me.get("groupSlug") or me.get("group_slug") + + local_id = db.upsert_household( + mealie_household_id=str(h_id_mealie), + name=str(h_name), + mealie_group_id=str(g_id_mealie) if g_id_mealie else None, + ) existing = db.list_household_member_subs(local_id) role = "admin" if not existing else "member" db.add_household_member(local_id, sub, role=role) @@ -1933,6 +1948,19 @@ def create_app() -> Flask: offset=max(int(args.get("offset") or 0), 0), ) + # Decorate each row with per-household / per-group import status. + # Mealie's group-share semantics mean a recipe imported by household-A + # is visible to household-B in the same group via group-scoped read. + # So: hide the import button if ANY household in the caller's group + # has imported it; show it otherwise. + my_hid = current_household_id() + my_household = db.get_household(my_hid) if my_hid else None + my_group_id = (my_household or {}).get("mealie_group_id") + group_imports = ( + db.get_discover_imports_for_group(mealie_group_id=my_group_id) + if my_group_id else {} + ) + out = [] for r in rows: meta = r.get("meta_json") @@ -1948,17 +1976,44 @@ def create_app() -> Flask: # scraped_json can be heavy — drop it from list responses r.pop("scraped_json", None) r["meta_json"] = meta + + imp = group_imports.get(r["id"]) + if imp: + r["imported_in_my_group"] = True + r["imported_by_my_household"] = (imp["household_id"] == my_hid) + r["mealie_slug"] = imp["mealie_slug"] + r["imported_by_household_name"] = imp["household_name"] + r["imported_at"] = imp["imported_at"] + else: + r["imported_in_my_group"] = False + r["imported_by_my_household"] = False out.append(r) return jsonify({"recipes": out, "count": len(out)}) @app.post("/api/discover/import/") @require_session def discover_import(discover_id: int): + u = session["user"] row = db.get_discovered_recipe(discover_id) if not row: return jsonify({"error": "not_found"}), 404 - if row.get("status") == "imported": - return jsonify({"error": "already_imported"}), 409 + if row.get("status") == "rejected": + return jsonify({"error": "row_rejected"}), 409 + hid = current_household_id() + if not hid: + return jsonify({"error": "no_household"}), 409 + # Per-household idempotency: if THIS household already imported this + # discover row, return the cached slug instead of double-importing + # (which Mealie would happily accept and create "(1)" suffixed dup). + existing = db.discover_imported_by_household( + discover_id=discover_id, household_id=hid + ) + if existing: + return jsonify({ + "ok": True, + "slug": existing["mealie_slug"], + "already_imported": True, + }) client = current_user_mealie() if client is None: return jsonify({"error": "mealie_not_connected"}), 409 @@ -1966,7 +2021,12 @@ def create_app() -> Flask: new_slug = client.import_from_url(row["source_url"]) except MealieError as e: return jsonify({"error": "mealie_import_failed", "detail": str(e)[:300]}), 502 - db.set_discovered_status(discover_id, "imported") + db.record_discover_import( + discover_id=discover_id, + household_id=hid, + mealie_slug=new_slug, + imported_by_sub=u["sub"], + ) return jsonify({"ok": True, "slug": new_slug}) @app.post("/api/discover/reject/") diff --git a/cauldron/templates/discover.html b/cauldron/templates/discover.html index 192cf21..d4f10ca 100644 --- a/cauldron/templates/discover.html +++ b/cauldron/templates/discover.html @@ -211,14 +211,27 @@ const quip = meta && meta.hecate_quip ? meta.hecate_quip : ''; const desc = r.description || (meta && meta.summary) || ''; const imgUrl = r.image_url || ''; - const klass = 'dcard ' + (r.status === 'imported' ? 'imported' : + // klass: dim imported (already in this group's library) and rejected. + // imported_in_my_group is set server-side via the cauldron_discover_imports + // join — covers both "I imported it" and "the other household imported it"; + // since Mealie's group-share read makes the recipe visible to both, hiding + // the import button is correct in either case. + const klass = 'dcard ' + (r.imported_in_my_group ? 'imported' : r.status === 'rejected' ? 'rejected' : ''); const imgHtml = imgUrl ? `
` : `
🍴
`; let actionsHtml = ''; - if(r.status === 'imported'){ - actionsHtml = '✓ imported'; + if(r.imported_in_my_group){ + const slug = r.mealie_slug || ''; + let badge; + if(r.imported_by_my_household){ + badge = '✓ in your library' + (slug ? ' as ' + _esc(slug) + '' : ''); + } else { + const who = r.imported_by_household_name || 'another household'; + badge = '✓ shared from ' + _esc(who) + (slug ? ' as ' + _esc(slug) + '' : ''); + } + actionsHtml = '' + badge + ''; } else if(r.status === 'rejected'){ actionsHtml = '✗ rejected'; } else { @@ -275,12 +288,15 @@ const r = await fetch('/api/discover/import/' + id, { method:'POST' }); const d = await r.json(); if(!r.ok) throw new Error(d.error || r.status); - // Mark card as imported in-place + // Mark card as imported-by-this-household in-place. The badge wording + // matches what _renderCard would emit on next refresh. const card = btn.closest('.dcard'); if(card){ card.classList.add('imported'); + const slugFrag = d.slug ? ' as ' + _esc(d.slug) + '' : ''; + const note = d.already_imported ? '✓ already in your library' : '✓ in your library'; card.querySelector('.actions').innerHTML = - '✓ imported as ' + _esc(d.slug) + ''; + '' + note + slugFrag + ''; } } catch(e){ btn.disabled = false; btn.textContent = '🍳 import';