discover: per-household imports + group-aware UI

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 <slug>" when this household imported, or
  "✓ shared from <Other Household> as <slug>" 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.
This commit is contained in:
Kayos 2026-05-01 20:40:56 -07:00
parent 09d716116a
commit fb94da7cce
3 changed files with 235 additions and 16 deletions

View file

@ -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(

View file

@ -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/<int:discover_id>")
@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/<int:discover_id>")

View file

@ -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
? `<div class="img" style="background-image:url('${_esc(imgUrl)}')"></div>`
: `<div class="img placeholder">🍴</div>`;
let actionsHtml = '';
if(r.status === 'imported'){
actionsHtml = '<span class="muted" style="flex:1; text-align:center; font-family:var(--mono); font-size:11px;">✓ imported</span>';
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 <code>' + _esc(slug) + '</code>' : '');
} else {
const who = r.imported_by_household_name || 'another household';
badge = '✓ shared from ' + _esc(who) + (slug ? ' as <code>' + _esc(slug) + '</code>' : '');
}
actionsHtml = '<span class="muted" style="flex:1; text-align:center; font-family:var(--mono); font-size:11px;">' + badge + '</span>';
} else if(r.status === 'rejected'){
actionsHtml = '<span class="muted" style="flex:1; text-align:center; font-family:var(--mono); font-size:11px;">✗ rejected</span>';
} 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 <code>' + _esc(d.slug) + '</code>' : '';
const note = d.already_imported ? '✓ already in your library' : '✓ in your library';
card.querySelector('.actions').innerHTML =
'<span class="muted" style="flex:1; text-align:center; font-family:var(--mono); font-size:11px;">✓ imported as <code>' + _esc(d.slug) + '</code></span>';
'<span class="muted" style="flex:1; text-align:center; font-family:var(--mono); font-size:11px;">' + note + slugFrag + '</span>';
}
} catch(e){
btn.disabled = false; btn.textContent = '🍳 import';