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:
parent
09d716116a
commit
fb94da7cce
3 changed files with 235 additions and 16 deletions
157
cauldron/db.py
157
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(
|
||||
|
|
|
|||
|
|
@ -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>")
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue