From 0b74915ee0fd44899680816558f5b8417626e342 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 17:45:20 -0700 Subject: [PATCH 01/35] feat(community): wire COMMUNITY_DB_URL + COMMUNITY_PSEUDONYM_SALT into cloud compose --- .env.example | 7 +++++++ compose.cloud.yml | 3 +++ 2 files changed, 10 insertions(+) diff --git a/.env.example b/.env.example index a57f9a5..5bb33d6 100644 --- a/.env.example +++ b/.env.example @@ -83,3 +83,10 @@ DEMO_MODE=false # INSTACART_AFFILIATE_ID=circuitforge # Walmart Impact network affiliate ID (inline, path-based redirect) # WALMART_AFFILIATE_ID= + + +# Community PostgreSQL — shared across CF products (cloud only; leave unset for local dev) +# Points at cf-orch's cf-community-postgres container (port 5434 on the orch host). +# When unset, community write paths fail soft with a plain-language message. +# COMMUNITY_DB_URL=postgresql://cf_community:changeme@cf-orch-host:5434/cf_community +# COMMUNITY_PSEUDONYM_SALT=change-this-to-a-random-32-char-string diff --git a/compose.cloud.yml b/compose.cloud.yml index 29ef534..ae903ab 100644 --- a/compose.cloud.yml +++ b/compose.cloud.yml @@ -20,6 +20,9 @@ services: CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-} # cf-orch: route LLM calls through the coordinator for managed GPU inference CF_ORCH_URL: http://host.docker.internal:7700 + # Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft) + COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-} + COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-} extra_hosts: - "host.docker.internal:host-gateway" volumes: From 0b08fbb18d828ee06c228b05c74184e2451cb27c Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 18:06:44 -0700 Subject: [PATCH 02/35] =?UTF-8?q?fix:=20correct=20leftover=5Fmode=20tier?= =?UTF-8?q?=20in=20README=20=E2=80=94=20Free=20(5/day)=20not=20Premium?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tiers.py and recipe_engine.py have always implemented this as Free with a 5/day rate limit. README inherited a stale tier assignment from an earlier design that was superseded when the rate-limit approach was chosen. Closes #67 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 52894ac..2220e0d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea - **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable) - **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable) - **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable) -- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier) +- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Free, 5/day; unlimited at Paid+) - **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier - **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable @@ -68,7 +68,7 @@ cp .env.example .env | LLM style auto-classifier | — | BYOK | ✓ | | Meal planning | — | ✓ | ✓ | | Multi-household | — | — | ✓ | -| Leftover mode | — | — | ✓ | +| Leftover mode (5/day) | ✓ | ✓ | ✓ | BYOK = bring your own LLM backend (configure `~/.config/circuitforge/llm.yaml`) From b97cd5992033ec940f055d63006b770f735f47d9 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 08:13:39 -0700 Subject: [PATCH 03/35] =?UTF-8?q?feat(community):=20migration=20028=20?= =?UTF-8?q?=E2=80=94=20community=5Fpseudonyms=20table=20in=20per-user=20ki?= =?UTF-8?q?wi.db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/028_community_pseudonyms.sql | 21 +++++++++++++++++++ tests/db/test_migrations.py | 18 ++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 app/db/migrations/028_community_pseudonyms.sql create mode 100644 tests/db/test_migrations.py diff --git a/app/db/migrations/028_community_pseudonyms.sql b/app/db/migrations/028_community_pseudonyms.sql new file mode 100644 index 0000000..d5fede0 --- /dev/null +++ b/app/db/migrations/028_community_pseudonyms.sql @@ -0,0 +1,21 @@ +-- 028_community_pseudonyms.sql +-- Per-user pseudonym store: maps the user's chosen community display name +-- to their Directus user ID. This table lives in per-user kiwi.db only. +-- It is NEVER replicated to the community PostgreSQL — pseudonym isolation is by design. +-- +-- A user may have one active pseudonym. Old pseudonyms are retained for reference +-- (posts published under them keep their pseudonym attribution) but only one is +-- flagged as current (is_current = 1). + +CREATE TABLE IF NOT EXISTS community_pseudonyms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pseudonym TEXT NOT NULL, + directus_user_id TEXT NOT NULL, + is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Only one pseudonym can be current at a time per user +CREATE UNIQUE INDEX IF NOT EXISTS idx_community_pseudonyms_current + ON community_pseudonyms (directus_user_id) + WHERE is_current = 1; diff --git a/tests/db/test_migrations.py b/tests/db/test_migrations.py new file mode 100644 index 0000000..2ae6895 --- /dev/null +++ b/tests/db/test_migrations.py @@ -0,0 +1,18 @@ +import pytest +from pathlib import Path +from app.db.store import Store + + +@pytest.fixture +def tmp_db(tmp_path: Path) -> Path: + return tmp_path / "test.db" + + +def test_migration_028_adds_community_pseudonyms(tmp_db): + """Migration 028 adds community_pseudonyms table to per-user kiwi.db.""" + store = Store(tmp_db) + cur = store.conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='community_pseudonyms'" + ) + assert cur.fetchone() is not None + store.close() From 1a9a8579a286f40598ab9d194aa4cd90296136ea Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 09:02:44 -0700 Subject: [PATCH 04/35] feat(community): add COMMUNITY_DB_URL config + community features to tiers --- app/core/config.py | 10 ++++++++++ app/tiers.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/app/core/config.py b/app/core/config.py index 091b574..fa55bc1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -35,6 +35,16 @@ class Settings: # Database DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db"))) + # Community feature settings + COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None + COMMUNITY_PSEUDONYM_SALT: str = os.environ.get( + "COMMUNITY_PSEUDONYM_SALT", "kiwi-default-salt-change-in-prod" + ) + COMMUNITY_CLOUD_FEED_URL: str = os.environ.get( + "COMMUNITY_CLOUD_FEED_URL", + "https://menagerie.circuitforge.tech/kiwi/api/v1/community/posts", + ) + # Processing MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4")) USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes") diff --git a/app/tiers.py b/app/tiers.py index 652544f..293e3aa 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -16,6 +16,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({ "expiry_llm_matching", "receipt_ocr", "style_classifier", + "community_fork_adapt", }) # Feature → minimum tier required @@ -38,6 +39,8 @@ KIWI_FEATURES: dict[str, str] = { "style_picker": "paid", "recipe_collections": "paid", "style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable + "community_publish": "paid", # Publish plans/outcomes to community feed + "community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable) # Premium tier "multi_household": "premium", From 74c7272a50c55727e091da576fb54d550be23f25 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 09:19:57 -0700 Subject: [PATCH 05/35] =?UTF-8?q?feat(community):=20element=20snapshot=20?= =?UTF-8?q?=E2=80=94=20SFAH=20scores,=20allergen=20detection,=20dietary=20?= =?UTF-8?q?tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/community/__init__.py | 0 app/services/community/element_snapshot.py | 138 ++++++++++++++++++ tests/services/community/__init__.py | 0 .../community/test_element_snapshot.py | 78 ++++++++++ 4 files changed, 216 insertions(+) create mode 100644 app/services/community/__init__.py create mode 100644 app/services/community/element_snapshot.py create mode 100644 tests/services/community/__init__.py create mode 100644 tests/services/community/test_element_snapshot.py diff --git a/app/services/community/__init__.py b/app/services/community/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/community/element_snapshot.py b/app/services/community/element_snapshot.py new file mode 100644 index 0000000..1d850ac --- /dev/null +++ b/app/services/community/element_snapshot.py @@ -0,0 +1,138 @@ +# app/services/community/element_snapshot.py +# MIT License + +from __future__ import annotations + +from dataclasses import dataclass + +# Ingredient name substrings → allergen flag +_ALLERGEN_MAP: dict[str, str] = { + "milk": "dairy", "cream": "dairy", "cheese": "dairy", "butter": "dairy", + "yogurt": "dairy", "whey": "dairy", + "egg": "eggs", + "wheat": "gluten", "pasta": "gluten", "flour": "gluten", "bread": "gluten", + "barley": "gluten", "rye": "gluten", + "peanut": "nuts", "almond": "nuts", "cashew": "nuts", "walnut": "nuts", + "pecan": "nuts", "hazelnut": "nuts", "pistachio": "nuts", "macadamia": "nuts", + "soy": "soy", "tofu": "soy", "edamame": "soy", "miso": "soy", "tempeh": "soy", + "shrimp": "shellfish", "crab": "shellfish", "lobster": "shellfish", + "clam": "shellfish", "mussel": "shellfish", "scallop": "shellfish", + "fish": "fish", "salmon": "fish", "tuna": "fish", "cod": "fish", + "tilapia": "fish", "halibut": "fish", + "sesame": "sesame", +} + +_MEAT_KEYWORDS = frozenset([ + "chicken", "beef", "pork", "lamb", "turkey", "bacon", "ham", "sausage", + "salami", "prosciutto", "guanciale", "pancetta", "steak", "ground meat", + "mince", "veal", "duck", "venison", "bison", "lard", +]) +_SEAFOOD_KEYWORDS = frozenset([ + "fish", "shrimp", "crab", "lobster", "tuna", "salmon", "clam", "mussel", + "scallop", "anchovy", "sardine", "cod", "tilapia", +]) +_ANIMAL_PRODUCT_KEYWORDS = frozenset([ + "milk", "cream", "cheese", "butter", "egg", "honey", "yogurt", "whey", +]) + + +def _detect_allergens(ingredient_names: list[str]) -> list[str]: + found: set[str] = set() + lowered = [n.lower() for n in ingredient_names] + for ingredient in lowered: + for keyword, flag in _ALLERGEN_MAP.items(): + if keyword in ingredient: + found.add(flag) + return sorted(found) + + +def _detect_dietary_tags(ingredient_names: list[str]) -> list[str]: + lowered = [n.lower() for n in ingredient_names] + all_text = " ".join(lowered) + + has_meat = any(k in all_text for k in _MEAT_KEYWORDS) + has_seafood = any(k in all_text for k in _SEAFOOD_KEYWORDS) + has_animal_products = any(k in all_text for k in _ANIMAL_PRODUCT_KEYWORDS) + + tags: list[str] = [] + if not has_meat and not has_seafood: + tags.append("vegetarian") + if not has_meat and not has_seafood and not has_animal_products: + tags.append("vegan") + return tags + + +@dataclass(frozen=True) +class ElementSnapshot: + seasoning_score: float + richness_score: float + brightness_score: float + depth_score: float + aroma_score: float + structure_score: float + texture_profile: str + dietary_tags: tuple + allergen_flags: tuple + flavor_molecules: tuple + fat_pct: float | None + protein_pct: float | None + moisture_pct: float | None + + +def compute_snapshot(recipe_ids: list[int], store) -> ElementSnapshot: + """Compute an element snapshot from a list of recipe IDs. + + Pulls SFAH scores, ingredient lists, and USDA FDC macros from the corpus. + Averages numeric scores across all recipes. Unions allergen flags and dietary tags. + Call at publish time only — snapshot is stored denormalized in community_posts. + """ + if not recipe_ids: + return ElementSnapshot( + seasoning_score=0.0, richness_score=0.0, brightness_score=0.0, + depth_score=0.0, aroma_score=0.0, structure_score=0.0, + texture_profile="", dietary_tags=(), allergen_flags=(), + flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None, + ) + + rows = store.get_recipes_by_ids(recipe_ids) + if not rows: + return ElementSnapshot( + seasoning_score=0.0, richness_score=0.0, brightness_score=0.0, + depth_score=0.0, aroma_score=0.0, structure_score=0.0, + texture_profile="", dietary_tags=(), allergen_flags=(), + flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None, + ) + + def _avg(field: str) -> float: + vals = [r.get(field) or 0.0 for r in rows] + return sum(vals) / len(vals) + + all_ingredients: list[str] = [] + for r in rows: + names = r.get("ingredient_names") or [] + all_ingredients.extend(names if isinstance(names, list) else []) + + allergens = _detect_allergens(all_ingredients) + dietary = _detect_dietary_tags(all_ingredients) + + texture = rows[0].get("texture_profile") or "" + + fat_vals = [r.get("fat") for r in rows if r.get("fat") is not None] + prot_vals = [r.get("protein") for r in rows if r.get("protein") is not None] + moist_vals = [r.get("moisture") for r in rows if r.get("moisture") is not None] + + return ElementSnapshot( + seasoning_score=_avg("seasoning_score"), + richness_score=_avg("richness_score"), + brightness_score=_avg("brightness_score"), + depth_score=_avg("depth_score"), + aroma_score=_avg("aroma_score"), + structure_score=_avg("structure_score"), + texture_profile=texture, + dietary_tags=tuple(dietary), + allergen_flags=tuple(allergens), + flavor_molecules=(), + fat_pct=(sum(fat_vals) / len(fat_vals)) if fat_vals else None, + protein_pct=(sum(prot_vals) / len(prot_vals)) if prot_vals else None, + moisture_pct=(sum(moist_vals) / len(moist_vals)) if moist_vals else None, + ) diff --git a/tests/services/community/__init__.py b/tests/services/community/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/community/test_element_snapshot.py b/tests/services/community/test_element_snapshot.py new file mode 100644 index 0000000..3437abf --- /dev/null +++ b/tests/services/community/test_element_snapshot.py @@ -0,0 +1,78 @@ +# tests/services/community/test_element_snapshot.py +import pytest +from unittest.mock import MagicMock +from app.services.community.element_snapshot import compute_snapshot, ElementSnapshot + + +def make_mock_store(recipe_rows: list[dict]) -> MagicMock: + """Return a mock Store whose get_recipes_by_ids returns the given rows.""" + store = MagicMock() + store.get_recipes_by_ids.return_value = recipe_rows + return store + + +RECIPE_ROW = { + "id": 1, + "name": "Spaghetti Carbonara", + "ingredient_names": ["pasta", "eggs", "guanciale", "pecorino"], + "keywords": ["italian", "quick", "dinner"], + "category": "dinner", + "fat": 22.0, + "protein": 18.0, + "moisture": 45.0, + "seasoning_score": 0.7, + "richness_score": 0.8, + "brightness_score": 0.2, + "depth_score": 0.6, + "aroma_score": 0.5, + "structure_score": 0.9, + "texture_profile": "creamy", +} + + +def test_compute_snapshot_basic(): + store = make_mock_store([RECIPE_ROW]) + snap = compute_snapshot(recipe_ids=[1], store=store) + assert isinstance(snap, ElementSnapshot) + assert 0.0 <= snap.seasoning_score <= 1.0 + assert snap.texture_profile == "creamy" + + +def test_compute_snapshot_averages_multiple_recipes(): + row2 = {**RECIPE_ROW, "id": 2, "seasoning_score": 0.3, "richness_score": 0.2} + store = make_mock_store([RECIPE_ROW, row2]) + snap = compute_snapshot(recipe_ids=[1, 2], store=store) + # seasoning average of 0.7 and 0.3 = 0.5 + assert abs(snap.seasoning_score - 0.5) < 0.01 + + +def test_compute_snapshot_allergen_flags_detected(): + row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "milk", "shrimp", "peanuts"]} + store = make_mock_store([row]) + snap = compute_snapshot(recipe_ids=[1], store=store) + assert "gluten" in snap.allergen_flags # pasta + assert "dairy" in snap.allergen_flags # milk + assert "shellfish" in snap.allergen_flags # shrimp + assert "nuts" in snap.allergen_flags # peanuts + + +def test_compute_snapshot_dietary_tags_vegetarian(): + row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "tomato", "basil"]} + store = make_mock_store([row]) + snap = compute_snapshot(recipe_ids=[1], store=store) + assert "vegetarian" in snap.dietary_tags + + +def test_compute_snapshot_no_recipes_returns_defaults(): + store = make_mock_store([]) + snap = compute_snapshot(recipe_ids=[], store=store) + assert snap.seasoning_score == 0.0 + assert snap.dietary_tags == () + assert snap.allergen_flags == () + + +def test_element_snapshot_immutable(): + store = make_mock_store([RECIPE_ROW]) + snap = compute_snapshot(recipe_ids=[1], store=store) + with pytest.raises((AttributeError, TypeError)): + snap.seasoning_score = 0.0 # type: ignore From f12699349b84313f17328a806306e7f107ac2c0a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 09:44:51 -0700 Subject: [PATCH 06/35] feat(community): RSS 2.0 feed generator + ActivityPub JSON-LD scaffold --- app/services/community/ap_compat.py | 44 +++++++++++++++++++ app/services/community/feed.py | 43 ++++++++++++++++++ tests/services/community/test_ap_compat.py | 48 ++++++++++++++++++++ tests/services/community/test_feed.py | 51 ++++++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 app/services/community/ap_compat.py create mode 100644 app/services/community/feed.py create mode 100644 tests/services/community/test_ap_compat.py create mode 100644 tests/services/community/test_feed.py diff --git a/app/services/community/ap_compat.py b/app/services/community/ap_compat.py new file mode 100644 index 0000000..4b5bf59 --- /dev/null +++ b/app/services/community/ap_compat.py @@ -0,0 +1,44 @@ +# app/services/community/ap_compat.py +# MIT License — AP scaffold only (no actor, inbox, outbox) + +from __future__ import annotations + +from datetime import datetime, timezone + + +def post_to_ap_json_ld(post: dict, base_url: str) -> dict: + """Serialize a community post dict to an ActivityPub-compatible JSON-LD Note. + + This is a read-only scaffold. No AP actor, inbox, or outbox. + The slug URI is stable so a future full AP implementation can reuse posts + without a DB migration. + """ + slug = post["slug"] + published = post.get("published") + if isinstance(published, datetime): + published_str = published.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + else: + published_str = str(published) + + dietary_tags: list[str] = post.get("dietary_tags") or [] + tags = [{"type": "Hashtag", "name": "#kiwi"}] + for tag in dietary_tags: + tags.append({"type": "Hashtag", "name": f"#{tag.replace('-', '').replace(' ', '')}"}) + + return { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Note", + "id": f"{base_url}/api/v1/community/posts/{slug}", + "attributedTo": post.get("pseudonym", "anonymous"), + "content": _build_content(post), + "published": published_str, + "tag": tags, + } + + +def _build_content(post: dict) -> str: + title = post.get("title") or "Untitled" + desc = post.get("description") + if desc: + return f"{title} — {desc}" + return title diff --git a/app/services/community/feed.py b/app/services/community/feed.py new file mode 100644 index 0000000..36000b9 --- /dev/null +++ b/app/services/community/feed.py @@ -0,0 +1,43 @@ +# app/services/community/feed.py +# MIT License + +from __future__ import annotations + +from datetime import datetime, timezone +from email.utils import format_datetime +from xml.etree.ElementTree import Element, SubElement, tostring + + +def posts_to_rss(posts: list[dict], base_url: str) -> str: + """Generate an RSS 2.0 feed from a list of community post dicts. + + base_url: the root URL of this Kiwi instance (no trailing slash). + Returns UTF-8 XML string. + """ + rss = Element("rss", version="2.0") + channel = SubElement(rss, "channel") + + _sub(channel, "title", "Kiwi Community Feed") + _sub(channel, "link", f"{base_url}/community") + _sub(channel, "description", "Meal plans and recipe outcomes from the Kiwi community") + _sub(channel, "language", "en") + _sub(channel, "lastBuildDate", format_datetime(datetime.now(timezone.utc))) + + for post in posts: + item = SubElement(channel, "item") + _sub(item, "title", post.get("title") or "Untitled") + _sub(item, "link", f"{base_url}/api/v1/community/posts/{post['slug']}") + _sub(item, "guid", f"{base_url}/api/v1/community/posts/{post['slug']}") + if post.get("description"): + _sub(item, "description", post["description"]) + published = post.get("published") + if isinstance(published, datetime): + _sub(item, "pubDate", format_datetime(published)) + + return '\n' + tostring(rss, encoding="unicode") + + +def _sub(parent: Element, tag: str, text: str) -> Element: + el = SubElement(parent, tag) + el.text = text + return el diff --git a/tests/services/community/test_ap_compat.py b/tests/services/community/test_ap_compat.py new file mode 100644 index 0000000..f91aa86 --- /dev/null +++ b/tests/services/community/test_ap_compat.py @@ -0,0 +1,48 @@ +# tests/services/community/test_ap_compat.py +import pytest +import json +from datetime import datetime, timezone +from app.services.community.ap_compat import post_to_ap_json_ld + + +POST = { + "slug": "kiwi-plan-test-pasta-week", + "title": "Pasta Week", + "description": "Seven days of carbs", + "published": datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc), + "pseudonym": "PastaWitch", + "dietary_tags": ["vegetarian"], +} + + +def test_ap_json_ld_context(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["@context"] == "https://www.w3.org/ns/activitystreams" + + +def test_ap_json_ld_type(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["type"] == "Note" + + +def test_ap_json_ld_id_is_uri(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["id"].startswith("https://") + assert POST["slug"] in doc["id"] + + +def test_ap_json_ld_published_is_iso8601(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + from datetime import datetime + datetime.fromisoformat(doc["published"].replace("Z", "+00:00")) + + +def test_ap_json_ld_attributed_to_pseudonym(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["attributedTo"] == "PastaWitch" + + +def test_ap_json_ld_tags_include_kiwi(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + tag_names = [t["name"] for t in doc.get("tag", [])] + assert "#kiwi" in tag_names diff --git a/tests/services/community/test_feed.py b/tests/services/community/test_feed.py new file mode 100644 index 0000000..bc23f8c --- /dev/null +++ b/tests/services/community/test_feed.py @@ -0,0 +1,51 @@ +# tests/services/community/test_feed.py +import pytest +from datetime import datetime, timezone +from app.services.community.feed import posts_to_rss + + +def make_post_dict(**kwargs): + defaults = dict( + slug="kiwi-plan-test-pasta-week", + title="Pasta Week", + description="Seven days of carbs", + published=datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc), + post_type="plan", + pseudonym="PastaWitch", + ) + defaults.update(kwargs) + return defaults + + +def test_rss_is_valid_xml(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + assert root.tag == "rss" + assert root.attrib.get("version") == "2.0" + + +def test_rss_contains_item(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + items = root.findall(".//item") + assert len(items) == 1 + + +def test_rss_item_has_required_fields(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + item = root.find(".//item") + assert item.find("title") is not None + assert item.find("link") is not None + assert item.find("pubDate") is not None + + +def test_rss_empty_posts(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + items = root.findall(".//item") + assert len(items) == 0 From b1ed369ea63b6787a53c6959b6eed818ad47fe66 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 09:59:50 -0700 Subject: [PATCH 07/35] =?UTF-8?q?feat(community):=20mDNS=20advertisement?= =?UTF-8?q?=20via=20zeroconf=20=E2=80=94=20defaults=20OFF,=20opt-in=20per?= =?UTF-8?q?=20a11y=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/community/mdns.py | 72 +++++++++++++++++++++++++++ pyproject.toml | 2 + tests/services/community/test_mdns.py | 39 +++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 app/services/community/mdns.py create mode 100644 tests/services/community/test_mdns.py diff --git a/app/services/community/mdns.py b/app/services/community/mdns.py new file mode 100644 index 0000000..148780a --- /dev/null +++ b/app/services/community/mdns.py @@ -0,0 +1,72 @@ +# app/services/community/mdns.py +# MIT License + +from __future__ import annotations + +import logging +import socket + +logger = logging.getLogger(__name__) + +# Import deferred to avoid hard failure when zeroconf is not installed +try: + from zeroconf import ServiceInfo, Zeroconf + _ZEROCONF_AVAILABLE = True +except ImportError: + _ZEROCONF_AVAILABLE = False + + +class KiwiMDNS: + """Advertise this Kiwi instance on the LAN via mDNS (_kiwi._tcp.local). + + Defaults to disabled (enabled=False). User must explicitly opt in via the + Settings page. This matches the CF a11y requirement: no surprise broadcasting. + + Usage: + mdns = KiwiMDNS(enabled=settings.MDNS_ENABLED, port=settings.PORT, + feed_url=f"http://{hostname}:{settings.PORT}/api/v1/community/local-feed") + mdns.start() # in lifespan startup + mdns.stop() # in lifespan shutdown + """ + + SERVICE_TYPE = "_kiwi._tcp.local." + + def __init__(self, enabled: bool, port: int, feed_url: str) -> None: + self._enabled = enabled + self._port = port + self._feed_url = feed_url + self._zc: "Zeroconf | None" = None + self._info: "ServiceInfo | None" = None + + def start(self) -> None: + if not self._enabled: + logger.debug("mDNS advertisement disabled (user has not opted in)") + return + if not _ZEROCONF_AVAILABLE: + logger.warning("zeroconf package not installed — mDNS advertisement unavailable") + return + + hostname = socket.gethostname() + service_name = f"kiwi-{hostname}.{self.SERVICE_TYPE}" + self._info = ServiceInfo( + type_=self.SERVICE_TYPE, + name=service_name, + port=self._port, + properties={ + b"feed_url": self._feed_url.encode(), + b"version": b"1", + }, + addresses=[socket.inet_aton("127.0.0.1")], + ) + self._zc = Zeroconf() + self._zc.register_service(self._info) + logger.info("mDNS: advertising %s on port %d", service_name, self._port) + + def stop(self) -> None: + if self._zc is None or self._info is None: + return + self._zc.unregister_service(self._info) + self._zc.close() + self._zc = None + self._info = None + logger.info("mDNS: advertisement stopped") diff --git a/pyproject.toml b/pyproject.toml index 1928abb..736139b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ # HTTP clients "httpx>=0.27", "requests>=2.31", + # mDNS advertisement (optional; user must opt in) + "zeroconf>=0.131", # CircuitForge shared scaffold "circuitforge-core>=0.8.0", ] diff --git a/tests/services/community/test_mdns.py b/tests/services/community/test_mdns.py new file mode 100644 index 0000000..8cfd4d3 --- /dev/null +++ b/tests/services/community/test_mdns.py @@ -0,0 +1,39 @@ +# tests/services/community/test_mdns.py +import pytest +from unittest.mock import MagicMock, patch +from app.services.community.mdns import KiwiMDNS + + +def test_mdns_does_not_advertise_when_disabled(): + """When enabled=False, KiwiMDNS does not register any zeroconf service.""" + with patch("app.services.community.mdns.Zeroconf") as mock_zc: + mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed") + mdns.start() + mock_zc.assert_not_called() + + +def test_mdns_advertises_when_enabled(): + with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls: + with patch("app.services.community.mdns.ServiceInfo") as mock_si: + mock_zc = MagicMock() + mock_zc_cls.return_value = mock_zc + mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed") + mdns.start() + mock_zc.register_service.assert_called_once() + + +def test_mdns_stop_unregisters_when_enabled(): + with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls: + with patch("app.services.community.mdns.ServiceInfo"): + mock_zc = MagicMock() + mock_zc_cls.return_value = mock_zc + mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed") + mdns.start() + mdns.stop() + mock_zc.unregister_service.assert_called_once() + mock_zc.close.assert_called_once() + + +def test_mdns_stop_is_noop_when_not_started(): + mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost/feed") + mdns.stop() # must not raise From 81107ed23850bdeb632692bdc39c668f4c42208a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 10:54:13 -0700 Subject: [PATCH 08/35] feat(community): KiwiCommunityStore + pseudonym helpers in per-user store --- app/db/store.py | 26 ++++++ app/services/community/community_store.py | 90 +++++++++++++++++++ .../community/test_community_store.py | 43 +++++++++ 3 files changed, 159 insertions(+) create mode 100644 app/services/community/community_store.py create mode 100644 tests/services/community/test_community_store.py diff --git a/app/db/store.py b/app/db/store.py index d931b5e..ce872ee 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1011,3 +1011,29 @@ class Store: (domain, category, page, result_count), ) self.conn.commit() + + def get_current_pseudonym(self, directus_user_id: str) -> str | None: + """Return the current community pseudonym for this user, or None if not set.""" + cur = self.conn.execute( + "SELECT pseudonym FROM community_pseudonyms " + "WHERE directus_user_id = ? AND is_current = 1 LIMIT 1", + (directus_user_id,), + ) + row = cur.fetchone() + return row["pseudonym"] if row else None + + def set_pseudonym(self, directus_user_id: str, pseudonym: str) -> None: + """Set the current community pseudonym for this user. + + Marks any previous pseudonym as non-current (retains history for attribution). + """ + self.conn.execute( + "UPDATE community_pseudonyms SET is_current = 0 WHERE directus_user_id = ?", + (directus_user_id,), + ) + self.conn.execute( + "INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) " + "VALUES (?, ?, 1)", + (pseudonym, directus_user_id), + ) + self.conn.commit() diff --git a/app/services/community/community_store.py b/app/services/community/community_store.py new file mode 100644 index 0000000..bd8eeea --- /dev/null +++ b/app/services/community/community_store.py @@ -0,0 +1,90 @@ +# app/services/community/community_store.py +# MIT License + +from __future__ import annotations + +import logging + +from circuitforge_core.community import CommunityPost, SharedStore + +logger = logging.getLogger(__name__) + + +class KiwiCommunityStore(SharedStore): + """Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore.""" + + def list_meal_plans( + self, + limit: int = 20, + offset: int = 0, + dietary_tags: list[str] | None = None, + allergen_exclude: list[str] | None = None, + ) -> list[CommunityPost]: + return self.list_posts( + limit=limit, + offset=offset, + post_type="plan", + dietary_tags=dietary_tags, + allergen_exclude=allergen_exclude, + source_product="kiwi", + ) + + def list_outcomes( + self, + limit: int = 20, + offset: int = 0, + post_type: str | None = None, + ) -> list[CommunityPost]: + if post_type in ("recipe_success", "recipe_blooper"): + return self.list_posts( + limit=limit, + offset=offset, + post_type=post_type, + source_product="kiwi", + ) + success = self.list_posts( + limit=limit, + offset=0, + post_type="recipe_success", + source_product="kiwi", + ) + bloopers = self.list_posts( + limit=limit, + offset=0, + post_type="recipe_blooper", + source_product="kiwi", + ) + merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True) + return merged[:limit] + + +def get_or_create_pseudonym( + store, + directus_user_id: str, + requested_name: str | None, +) -> str: + """Return the user's current pseudonym, creating it if it doesn't exist. + + If the user has an existing pseudonym, return it (ignore requested_name). + If not, create using requested_name (must be provided for first-time setup). + + Raises ValueError if no existing pseudonym and requested_name is None or blank. + """ + existing = store.get_current_pseudonym(directus_user_id) + if existing: + return existing + + if not requested_name or not requested_name.strip(): + raise ValueError( + "A pseudonym is required for first publish. " + "Pass requested_name with the user's chosen display name." + ) + + name = requested_name.strip() + if "@" in name: + raise ValueError( + "Pseudonym must not contain '@' — use a display name, not an email address." + ) + + store.set_pseudonym(directus_user_id, name) + return name diff --git a/tests/services/community/test_community_store.py b/tests/services/community/test_community_store.py new file mode 100644 index 0000000..93fc620 --- /dev/null +++ b/tests/services/community/test_community_store.py @@ -0,0 +1,43 @@ +# tests/services/community/test_community_store.py +# MIT License + +import pytest +from unittest.mock import MagicMock, patch +from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym + + +def test_get_or_create_pseudonym_new_user(): + """First-time publish: creates a new pseudonym in per-user SQLite.""" + mock_store = MagicMock() + mock_store.get_current_pseudonym.return_value = None + result = get_or_create_pseudonym( + store=mock_store, + directus_user_id="user-123", + requested_name="PastaWitch", + ) + mock_store.set_pseudonym.assert_called_once_with("user-123", "PastaWitch") + assert result == "PastaWitch" + + +def test_get_or_create_pseudonym_existing(): + """If user already has a pseudonym, return it without creating a new one.""" + mock_store = MagicMock() + mock_store.get_current_pseudonym.return_value = "PastaWitch" + result = get_or_create_pseudonym( + store=mock_store, + directus_user_id="user-123", + requested_name=None, + ) + mock_store.set_pseudonym.assert_not_called() + assert result == "PastaWitch" + + +def test_kiwi_community_store_list_meal_plans(): + """KiwiCommunityStore.list_meal_plans filters by post_type='plan'.""" + mock_db = MagicMock() + store = KiwiCommunityStore(mock_db) + with patch.object(store, "list_posts", return_value=[]) as mock_list: + result = store.list_meal_plans(limit=10) + mock_list.assert_called_once() + call_kwargs = mock_list.call_args.kwargs + assert call_kwargs.get("post_type") == "plan" From 9c64de2acfe9ace1f89f5942bb58c5ea2ad1e464 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 11:14:18 -0700 Subject: [PATCH 09/35] =?UTF-8?q?feat(community):=20community=20API=20endp?= =?UTF-8?q?oints=20=E2=80=94=20browse,=20publish,=20fork,=20delete,=20RSS,?= =?UTF-8?q?=20AP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /community/posts, GET /community/posts/{slug}, GET /community/feed.rss, GET /community/local-feed, POST /community/posts, DELETE /community/posts/{slug}, POST /community/posts/{slug}/fork, and POST /community/posts/{slug}/fork-adapt (501 stub). Wires init_community_store into main.py lifespan. 7 new tests; 115 total passing. --- app/api/endpoints/community.py | 285 ++++++++++++++++++++++++++ app/api/routes.py | 8 +- app/main.py | 4 + tests/api/test_community_endpoints.py | 72 +++++++ 4 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 app/api/endpoints/community.py create mode 100644 tests/api/test_community_endpoints.py diff --git a/app/api/endpoints/community.py b/app/api/endpoints/community.py new file mode 100644 index 0000000..2da6fe7 --- /dev/null +++ b/app/api/endpoints/community.py @@ -0,0 +1,285 @@ +# app/api/endpoints/community.py +# MIT License + +from __future__ import annotations + +import asyncio +import logging +import re +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, Response + +from app.cloud_session import CloudUser, get_session +from app.core.config import settings +from app.db.store import Store +from app.services.community.feed import posts_to_rss + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/community", tags=["community"]) + +_community_store = None + + +def _get_community_store(): + return _community_store + + +def init_community_store(community_db_url: str | None) -> None: + global _community_store + if not community_db_url: + logger.info( + "COMMUNITY_DB_URL not set — community write features disabled. " + "Browse still works via cloud feed." + ) + return + from circuitforge_core.community import CommunityDB + from app.services.community.community_store import KiwiCommunityStore + db = CommunityDB(dsn=community_db_url) + db.run_migrations() + _community_store = KiwiCommunityStore(db) + logger.info("Community store initialized.") + + +@router.get("/posts") +async def list_posts( + post_type: str | None = None, + dietary_tags: str | None = None, + allergen_exclude: str | None = None, + page: int = 1, + page_size: int = 20, +): + store = _get_community_store() + if store is None: + return {"posts": [], "total": 0, "note": "Community DB not available on this instance."} + + dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None + allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None + offset = (page - 1) * min(page_size, 100) + + posts = await asyncio.to_thread( + store.list_posts, + limit=min(page_size, 100), + offset=offset, + post_type=post_type, + dietary_tags=dietary, + allergen_exclude=allergen_ex, + ) + return {"posts": [_post_to_dict(p) for p in posts], "page": page, "page_size": page_size} + + +@router.get("/posts/{slug}") +async def get_post(slug: str, request: Request): + store = _get_community_store() + if store is None: + raise HTTPException(status_code=503, detail="Community DB not available on this instance.") + + post = await asyncio.to_thread(store.get_post_by_slug, slug) + if post is None: + raise HTTPException(status_code=404, detail="Post not found.") + + accept = request.headers.get("accept", "") + if "application/activity+json" in accept: + from app.services.community.ap_compat import post_to_ap_json_ld + base_url = str(request.base_url).rstrip("/") + return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url) + + return _post_to_dict(post) + + +@router.get("/feed.rss") +async def get_rss_feed(request: Request): + store = _get_community_store() + posts_data: list[dict] = [] + if store is not None: + posts = await asyncio.to_thread(store.list_posts, limit=50) + posts_data = [_post_to_dict(p) for p in posts] + + base_url = str(request.base_url).rstrip("/") + rss = posts_to_rss(posts_data, base_url=base_url) + return Response(content=rss, media_type="application/rss+xml; charset=utf-8") + + +@router.get("/local-feed") +async def local_feed(): + store = _get_community_store() + if store is None: + return [] + posts = await asyncio.to_thread(store.list_posts, limit=50) + return [_post_to_dict(p) for p in posts] + + +@router.post("/posts", status_code=201) +async def publish_post(body: dict, session: CloudUser = Depends(get_session)): + from app.tiers import can_use + if not can_use("community_publish", session.tier, session.has_byok): + raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.") + + store = _get_community_store() + if store is None: + raise HTTPException( + status_code=503, + detail="This Kiwi instance is not connected to a community database. " + "Publishing is only available on cloud instances.", + ) + + from app.services.community.community_store import get_or_create_pseudonym + def _get_pseudonym(): + s = Store(session.db) + try: + return get_or_create_pseudonym( + store=s, + directus_user_id=session.user_id, + requested_name=body.get("pseudonym_name"), + ) + finally: + s.close() + pseudonym = await asyncio.to_thread(_get_pseudonym) + + recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")] + from app.services.community.element_snapshot import compute_snapshot + def _snapshot(): + s = Store(session.db) + try: + return compute_snapshot(recipe_ids=recipe_ids, store=s) + finally: + s.close() + snapshot = await asyncio.to_thread(_snapshot) + + slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-") + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + slug = f"kiwi-{_post_type_prefix(body.get('post_type', 'plan'))}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120] + + from circuitforge_core.community.models import CommunityPost + post = CommunityPost( + slug=slug, + pseudonym=pseudonym, + post_type=body.get("post_type", "plan"), + published=datetime.now(timezone.utc), + title=body.get("title", "Untitled"), + description=body.get("description"), + photo_url=body.get("photo_url"), + slots=body.get("slots", []), + recipe_id=body.get("recipe_id"), + recipe_name=body.get("recipe_name"), + level=body.get("level"), + outcome_notes=body.get("outcome_notes"), + seasoning_score=snapshot.seasoning_score, + richness_score=snapshot.richness_score, + brightness_score=snapshot.brightness_score, + depth_score=snapshot.depth_score, + aroma_score=snapshot.aroma_score, + structure_score=snapshot.structure_score, + texture_profile=snapshot.texture_profile, + dietary_tags=list(snapshot.dietary_tags), + allergen_flags=list(snapshot.allergen_flags), + flavor_molecules=list(snapshot.flavor_molecules), + fat_pct=snapshot.fat_pct, + protein_pct=snapshot.protein_pct, + moisture_pct=snapshot.moisture_pct, + ) + + inserted = await asyncio.to_thread(store.insert_post, post) + return _post_to_dict(inserted) + + +@router.delete("/posts/{slug}", status_code=204) +async def delete_post(slug: str, session: CloudUser = Depends(get_session)): + store = _get_community_store() + if store is None: + raise HTTPException(status_code=503, detail="Community DB not available.") + + def _get_pseudonym(): + s = Store(session.db) + try: + return s.get_current_pseudonym(session.user_id) + finally: + s.close() + pseudonym = await asyncio.to_thread(_get_pseudonym) + if not pseudonym: + raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.") + + deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym) + if not deleted: + raise HTTPException(status_code=404, detail="Post not found or you are not the author.") + + +@router.post("/posts/{slug}/fork", status_code=201) +async def fork_post(slug: str, session: CloudUser = Depends(get_session)): + store = _get_community_store() + if store is None: + raise HTTPException(status_code=503, detail="Community DB not available.") + + post = await asyncio.to_thread(store.get_post_by_slug, slug) + if post is None: + raise HTTPException(status_code=404, detail="Post not found.") + if post.post_type != "plan": + raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.") + + from datetime import date + week_start = date.today().strftime("%Y-%m-%d") + + def _create_plan(): + s = Store(session.db) + try: + meal_types = list({slot["meal_type"] for slot in post.slots}) + plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types or ["dinner"]) + for slot in post.slots: + s.assign_recipe_to_slot( + plan_id=plan["id"], + day_of_week=slot["day"], + meal_type=slot["meal_type"], + recipe_id=slot["recipe_id"], + ) + return plan + finally: + s.close() + + plan = await asyncio.to_thread(_create_plan) + return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug} + + +@router.post("/posts/{slug}/fork-adapt", status_code=201) +async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)): + from app.tiers import can_use + if not can_use("community_fork_adapt", session.tier, session.has_byok): + raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.") + # Stub: full LLM adaptation deferred + raise HTTPException(status_code=501, detail="Fork-adapt not yet implemented.") + + +def _post_to_dict(post) -> dict: + return { + "slug": post.slug, + "pseudonym": post.pseudonym, + "post_type": post.post_type, + "published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published), + "title": post.title, + "description": post.description, + "photo_url": post.photo_url, + "slots": list(post.slots), + "recipe_id": post.recipe_id, + "recipe_name": post.recipe_name, + "level": post.level, + "outcome_notes": post.outcome_notes, + "element_profiles": { + "seasoning_score": post.seasoning_score, + "richness_score": post.richness_score, + "brightness_score": post.brightness_score, + "depth_score": post.depth_score, + "aroma_score": post.aroma_score, + "structure_score": post.structure_score, + "texture_profile": post.texture_profile, + }, + "dietary_tags": list(post.dietary_tags), + "allergen_flags": list(post.allergen_flags), + "flavor_molecules": list(post.flavor_molecules), + "fat_pct": post.fat_pct, + "protein_pct": post.protein_pct, + "moisture_pct": post.moisture_pct, + } + + +def _post_type_prefix(post_type: str) -> str: + return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post") diff --git a/app/api/routes.py b/app/api/routes.py index ec9d25c..cd22a56 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate api_router = APIRouter() @@ -13,4 +13,8 @@ api_router.include_router(settings.router, prefix="/settings", tags=["setting api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) api_router.include_router(household.router, prefix="/household", tags=["household"]) -api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) \ No newline at end of file +api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) +api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) + +from app.api.endpoints.community import router as community_router +api_router.include_router(community_router) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 42e536a..5783585 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,10 @@ async def lifespan(app: FastAPI): get_scheduler(settings.DB_PATH) logger.info("Task scheduler started.") + # Initialize community store (no-op if COMMUNITY_DB_URL is not set) + from app.api.endpoints.community import init_community_store + init_community_store(settings.COMMUNITY_DB_URL) + yield # Graceful scheduler shutdown diff --git a/tests/api/test_community_endpoints.py b/tests/api/test_community_endpoints.py new file mode 100644 index 0000000..d67cf58 --- /dev/null +++ b/tests/api/test_community_endpoints.py @@ -0,0 +1,72 @@ +# tests/api/test_community_endpoints.py +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_get_community_posts_no_db_returns_empty(): + """When COMMUNITY_DB_URL is not set, GET /community/posts returns empty list (no 500).""" + with patch("app.api.endpoints.community._community_store", None): + response = client.get("/api/v1/community/posts") + assert response.status_code == 200 + data = response.json() + assert "posts" in data + assert isinstance(data["posts"], list) + + +def test_get_community_post_not_found(): + """GET /community/posts/{slug} returns 404 when slug doesn't exist.""" + mock_store = MagicMock() + mock_store.get_post_by_slug.return_value = None + with patch("app.api.endpoints.community._community_store", mock_store): + response = client.get("/api/v1/community/posts/nonexistent-slug") + assert response.status_code == 404 + + +def test_get_community_rss(): + """GET /community/feed.rss returns XML with content-type application/rss+xml.""" + mock_store = MagicMock() + mock_store.list_posts.return_value = [] + with patch("app.api.endpoints.community._community_store", mock_store): + response = client.get("/api/v1/community/feed.rss") + assert response.status_code == 200 + assert "xml" in response.headers.get("content-type", "") + + +def test_post_community_requires_auth(): + """POST /community/posts requires authentication (401/403/422) or community store (503). + + In local/dev mode get_session bypasses JWT auth and returns a privileged user, + so the next gate is the community store check (503 when COMMUNITY_DB_URL is not set). + In cloud mode the endpoint requires a valid session (401/403). + """ + response = client.post("/api/v1/community/posts", json={"title": "Test"}) + assert response.status_code in (401, 403, 422, 503) + + +def test_delete_post_requires_auth(): + """DELETE /community/posts/{slug} requires authentication (401/403) or community store (503). + + Same local-mode caveat as test_post_community_requires_auth. + """ + response = client.delete("/api/v1/community/posts/some-slug") + assert response.status_code in (401, 403, 422, 503) + + +def test_fork_post_route_exists(): + """POST /community/posts/{slug}/fork route exists (not 404).""" + response = client.post("/api/v1/community/posts/some-slug/fork") + assert response.status_code != 404 + + +def test_local_feed_returns_json(): + """GET /community/local-feed returns JSON list for LAN peers.""" + mock_store = MagicMock() + mock_store.list_posts.return_value = [] + with patch("app.api.endpoints.community._community_store", mock_store): + response = client.get("/api/v1/community/local-feed") + assert response.status_code == 200 + assert isinstance(response.json(), list) From 9ae886aabf3801eb94820f5c2f47796b90dbc6e4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 11:20:28 -0700 Subject: [PATCH 10/35] =?UTF-8?q?fix:=20community=20endpoint=20spec=20gaps?= =?UTF-8?q?=20=E2=80=94=20ld+json=20content=20negotiation=20+=20premium=20?= =?UTF-8?q?post=20tier=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/community.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/api/endpoints/community.py b/app/api/endpoints/community.py index 2da6fe7..2a96192 100644 --- a/app/api/endpoints/community.py +++ b/app/api/endpoints/community.py @@ -42,6 +42,15 @@ def init_community_store(community_db_url: str | None) -> None: logger.info("Community store initialized.") +def _visible(post, session=None) -> bool: + """Return False for premium-tier posts when the session is not paid/premium.""" + tier = getattr(post, "tier", None) + if tier == "premium": + if session is None or getattr(session, "tier", None) not in ("paid", "premium"): + return False + return True + + @router.get("/posts") async def list_posts( post_type: str | None = None, @@ -66,7 +75,7 @@ async def list_posts( dietary_tags=dietary, allergen_exclude=allergen_ex, ) - return {"posts": [_post_to_dict(p) for p in posts], "page": page, "page_size": page_size} + return {"posts": [_post_to_dict(p) for p in posts if _visible(p)], "page": page, "page_size": page_size} @router.get("/posts/{slug}") @@ -80,7 +89,7 @@ async def get_post(slug: str, request: Request): raise HTTPException(status_code=404, detail="Post not found.") accept = request.headers.get("accept", "") - if "application/activity+json" in accept: + if "application/activity+json" in accept or "application/ld+json" in accept: from app.services.community.ap_compat import post_to_ap_json_ld base_url = str(request.base_url).rstrip("/") return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url) From 69e1b700729ba3d59b45bfa95003acc15b99d087 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 11:23:45 -0700 Subject: [PATCH 11/35] =?UTF-8?q?fix:=20community=20endpoint=20quality=20i?= =?UTF-8?q?ssues=20=E2=80=94=20input=20validation,=20slot=20key=20guard,?= =?UTF-8?q?=20slug=20collision,=20ValueError=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/community.py | 54 ++++++++++++++++++++++++++++++---- app/api/routes.py | 27 ++++++++--------- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/app/api/endpoints/community.py b/app/api/endpoints/community.py index 2a96192..1aa6fc2 100644 --- a/app/api/endpoints/community.py +++ b/app/api/endpoints/community.py @@ -119,12 +119,39 @@ async def local_feed(): return [_post_to_dict(p) for p in posts] +_VALID_POST_TYPES = {"plan", "recipe_success", "recipe_blooper"} +_MAX_TITLE_LEN = 200 +_MAX_TEXT_LEN = 2000 + + +def _validate_publish_body(body: dict) -> None: + """Raise HTTPException(422) for any invalid fields in a publish request.""" + post_type = body.get("post_type", "plan") + if post_type not in _VALID_POST_TYPES: + raise HTTPException( + status_code=422, + detail=f"post_type must be one of: {', '.join(sorted(_VALID_POST_TYPES))}", + ) + title = body.get("title") or "" + if len(title) > _MAX_TITLE_LEN: + raise HTTPException(status_code=422, detail=f"title exceeds {_MAX_TITLE_LEN} character limit.") + for field in ("description", "outcome_notes", "recipe_name"): + value = body.get(field) + if value and len(str(value)) > _MAX_TEXT_LEN: + raise HTTPException(status_code=422, detail=f"{field} exceeds {_MAX_TEXT_LEN} character limit.") + photo_url = body.get("photo_url") + if photo_url and not str(photo_url).startswith("https://"): + raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.") + + @router.post("/posts", status_code=201) async def publish_post(body: dict, session: CloudUser = Depends(get_session)): from app.tiers import can_use if not can_use("community_publish", session.tier, session.has_byok): raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.") + _validate_publish_body(body) + store = _get_community_store() if store is None: raise HTTPException( @@ -144,7 +171,10 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)): ) finally: s.close() - pseudonym = await asyncio.to_thread(_get_pseudonym) + try: + pseudonym = await asyncio.to_thread(_get_pseudonym) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")] from app.services.community.element_snapshot import compute_snapshot @@ -156,17 +186,18 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)): s.close() snapshot = await asyncio.to_thread(_snapshot) + post_type = body.get("post_type", "plan") slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-") today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - slug = f"kiwi-{_post_type_prefix(body.get('post_type', 'plan'))}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120] + slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120] from circuitforge_core.community.models import CommunityPost post = CommunityPost( slug=slug, pseudonym=pseudonym, - post_type=body.get("post_type", "plan"), + post_type=post_type, published=datetime.now(timezone.utc), - title=body.get("title", "Untitled"), + title=(body.get("title") or "Untitled")[:_MAX_TITLE_LEN], description=body.get("description"), photo_url=body.get("photo_url"), slots=body.get("slots", []), @@ -189,7 +220,16 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)): moisture_pct=snapshot.moisture_pct, ) - inserted = await asyncio.to_thread(store.insert_post, post) + try: + inserted = await asyncio.to_thread(store.insert_post, post) + except Exception as exc: + exc_str = str(exc).lower() + if "unique" in exc_str or "duplicate" in exc_str: + raise HTTPException( + status_code=409, + detail="A post with this title already exists today. Try a different title.", + ) from exc + raise return _post_to_dict(inserted) @@ -226,6 +266,10 @@ async def fork_post(slug: str, session: CloudUser = Depends(get_session)): if post.post_type != "plan": raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.") + required_slot_keys = {"day", "meal_type", "recipe_id"} + if any(not required_slot_keys.issubset(slot) for slot in post.slots): + raise HTTPException(status_code=400, detail="Post contains malformed slots and cannot be forked.") + from datetime import date week_start = date.today().strftime("%Y-%m-%d") diff --git a/app/api/routes.py b/app/api/routes.py index cd22a56..97e2637 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,20 +1,19 @@ from fastapi import APIRouter from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate +from app.api.endpoints.community import router as community_router api_router = APIRouter() -api_router.include_router(health.router, prefix="/health", tags=["health"]) -api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"]) -api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) -api_router.include_router(export.router, tags=["export"]) -api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) -api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"]) -api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) -api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) -api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) -api_router.include_router(household.router, prefix="/household", tags=["household"]) -api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) -api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) - -from app.api.endpoints.community import router as community_router +api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"]) +api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) +api_router.include_router(export.router, tags=["export"]) +api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) +api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"]) +api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) +api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) +api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) +api_router.include_router(household.router, prefix="/household", tags=["household"]) +api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) +api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) api_router.include_router(community_router) \ No newline at end of file From 86dd9adbcbff4935e3d65d1403d80e84229c621a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 11:25:10 -0700 Subject: [PATCH 12/35] refactor: use sqlite3.IntegrityError directly for slug collision guard --- app/api/endpoints/community.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/api/endpoints/community.py b/app/api/endpoints/community.py index 1aa6fc2..818e17c 100644 --- a/app/api/endpoints/community.py +++ b/app/api/endpoints/community.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio import logging import re +import sqlite3 from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -222,14 +223,11 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)): try: inserted = await asyncio.to_thread(store.insert_post, post) - except Exception as exc: - exc_str = str(exc).lower() - if "unique" in exc_str or "duplicate" in exc_str: - raise HTTPException( - status_code=409, - detail="A post with this title already exists today. Try a different title.", - ) from exc - raise + except sqlite3.IntegrityError as exc: + raise HTTPException( + status_code=409, + detail="A post with this title already exists today. Try a different title.", + ) from exc return _post_to_dict(inserted) From 8731cad85422de19a5607714791d7f273178ef0d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 11:34:54 -0700 Subject: [PATCH 13/35] feat: community feed Vue frontend -- Pinia store + feed panel + RecipesView tab --- .../src/components/CommunityFeedPanel.vue | 251 ++++++++++++++++++ frontend/src/components/CommunityPostCard.vue | 172 ++++++++++++ frontend/src/components/RecipesView.vue | 25 +- frontend/src/stores/community.ts | 98 +++++++ 4 files changed, 541 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/CommunityFeedPanel.vue create mode 100644 frontend/src/components/CommunityPostCard.vue create mode 100644 frontend/src/stores/community.ts diff --git a/frontend/src/components/CommunityFeedPanel.vue b/frontend/src/components/CommunityFeedPanel.vue new file mode 100644 index 0000000..cdb4d7f --- /dev/null +++ b/frontend/src/components/CommunityFeedPanel.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/frontend/src/components/CommunityPostCard.vue b/frontend/src/components/CommunityPostCard.vue new file mode 100644 index 0000000..afa223c --- /dev/null +++ b/frontend/src/components/CommunityPostCard.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index e5d7700..7fba9fe 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -32,6 +32,14 @@ @open-recipe="openRecipeById" /> + + +
@@ -554,6 +562,7 @@ import { useInventoryStore } from '../stores/inventory' import RecipeDetailPanel from './RecipeDetailPanel.vue' import RecipeBrowserPanel from './RecipeBrowserPanel.vue' import SavedRecipesPanel from './SavedRecipesPanel.vue' +import CommunityFeedPanel from './CommunityFeedPanel.vue' import type { RecipeSuggestion, GroceryLink } from '../services/api' import { recipesAPI } from '../services/api' @@ -561,16 +570,17 @@ const recipesStore = useRecipesStore() const inventoryStore = useInventoryStore() // Tab state -type TabId = 'find' | 'browse' | 'saved' +type TabId = 'find' | 'browse' | 'saved' | 'community' const tabs: Array<{ id: TabId; label: string }> = [ - { id: 'find', label: 'Find' }, - { id: 'browse', label: 'Browse' }, - { id: 'saved', label: 'Saved' }, + { id: 'find', label: 'Find' }, + { id: 'browse', label: 'Browse' }, + { id: 'saved', label: 'Saved' }, + { id: 'community', label: 'Community' }, ] const activeTab = ref('find') function onTabKeydown(e: KeyboardEvent) { - const tabIds: TabId[] = ['find', 'browse', 'saved'] + const tabIds: TabId[] = ['find', 'browse', 'saved', 'community'] const current = tabIds.indexOf(activeTab.value) if (e.key === 'ArrowRight') { e.preventDefault() @@ -581,6 +591,11 @@ function onTabKeydown(e: KeyboardEvent) { } } +// Community tab: navigate to Find tab after a plan fork (full plan view deferred to Task 9) +function onPlanForked(_payload: { plan_id: number; week_start: string }) { + activeTab.value = 'find' +} + // Browser/saved tab recipe detail panel (fetches full recipe from API) const browserSelectedRecipe = ref(null) diff --git a/frontend/src/stores/community.ts b/frontend/src/stores/community.ts new file mode 100644 index 0000000..8abd6af --- /dev/null +++ b/frontend/src/stores/community.ts @@ -0,0 +1,98 @@ +/** + * Community Store + * + * Manages community post feed state and fork actions using Pinia. + * Follows the composition store pattern established in recipes.ts. + */ + +import { defineStore } from 'pinia' +import { ref } from 'vue' +import api from '../services/api' + +// ========== Types ========== + +export interface CommunityPostSlot { + day: string + meal_type: string + recipe_id: number +} + +export interface ElementProfiles { + seasoning_score: number | null + richness_score: number | null + brightness_score: number | null + depth_score: number | null + aroma_score: number | null + structure_score: number | null + texture_profile: string | null +} + +export interface CommunityPost { + slug: string + pseudonym: string + post_type: 'plan' | 'recipe_success' | 'recipe_blooper' + published: string + title: string + description: string | null + photo_url: string | null + slots: CommunityPostSlot[] + recipe_id: number | null + recipe_name: string | null + level: number | null + outcome_notes: string | null + element_profiles: ElementProfiles + dietary_tags: string[] + allergen_flags: string[] + flavor_molecules: string[] + fat_pct: number | null + protein_pct: number | null + moisture_pct: number | null +} + +export interface ForkResult { + plan_id: number + week_start: string + forked_from: string +} + +// ========== Store ========== + +export const useCommunityStore = defineStore('community', () => { + const posts = ref([]) + const loading = ref(false) + const error = ref(null) + const currentFilter = ref(null) + + async function fetchPosts(postType?: string) { + loading.value = true + error.value = null + currentFilter.value = postType ?? null + + try { + const params: Record = { page: 1, page_size: 40 } + if (postType) { + params.post_type = postType + } + const response = await api.get<{ posts: CommunityPost[] }>('/community/posts', { params }) + posts.value = response.data.posts + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : 'Could not load community posts.' + } finally { + loading.value = false + } + } + + async function forkPost(slug: string): Promise { + const response = await api.post(`/community/posts/${slug}/fork`) + return response.data + } + + return { + posts, + loading, + error, + currentFilter, + fetchPosts, + forkPost, + } +}) From 730445e479adbed1d2c156e666d3486b5442b2a1 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 11:38:17 -0700 Subject: [PATCH 14/35] fix: community feed a11y -- reduced-motion guards + tablist focus management --- frontend/src/components/CommunityFeedPanel.vue | 17 +++++++++++++++-- frontend/src/components/CommunityPostCard.vue | 6 ++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/CommunityFeedPanel.vue b/frontend/src/components/CommunityFeedPanel.vue index cdb4d7f..e941e8c 100644 --- a/frontend/src/components/CommunityFeedPanel.vue +++ b/frontend/src/components/CommunityFeedPanel.vue @@ -111,13 +111,21 @@ const filterIds = filters.map((f) => f.id) function onFilterKeydown(e: KeyboardEvent) { const current = filterIds.indexOf(activeFilter.value) + let next = current if (e.key === 'ArrowRight') { e.preventDefault() - setFilter(filterIds[(current + 1) % filterIds.length]!) + next = (current + 1) % filterIds.length } else if (e.key === 'ArrowLeft') { e.preventDefault() - setFilter(filterIds[(current - 1 + filterIds.length) % filterIds.length]!) + next = (current - 1 + filterIds.length) % filterIds.length + } else { + return } + setFilter(filterIds[next]!) + // Move DOM focus to the newly active tab per ARIA tablist pattern + const bar = (e.currentTarget as HTMLElement).closest('[role="tablist"]') + const buttons = bar?.querySelectorAll('[role="tab"]') + buttons?.[next]?.focus() } async function setFilter(filterId: string) { @@ -247,5 +255,10 @@ onMounted(async () => { animation: none; opacity: 0.7; } + + .toast-fade-enter-active, + .toast-fade-leave-active { + transition: none; + } } diff --git a/frontend/src/components/CommunityPostCard.vue b/frontend/src/components/CommunityPostCard.vue index afa223c..64c83b5 100644 --- a/frontend/src/components/CommunityPostCard.vue +++ b/frontend/src/components/CommunityPostCard.vue @@ -169,4 +169,10 @@ const fullDate = computed(() => { width: 100%; } } + +@media (prefers-reduced-motion: reduce) { + .community-post-card { + transition: none; + } +} From 9603d421b6b15dafe3d66176f27ee9aeb3f4c72c Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Apr 2026 11:45:32 -0700 Subject: [PATCH 15/35] feat: community publish modals -- focus traps, aria-live, plan + outcome forms --- .../src/components/CommunityFeedPanel.vue | 34 ++ .../src/components/PublishOutcomeModal.vue | 363 ++++++++++++++++++ frontend/src/components/PublishPlanModal.vue | 306 +++++++++++++++ frontend/src/stores/community.ts | 20 + 4 files changed, 723 insertions(+) create mode 100644 frontend/src/components/PublishOutcomeModal.vue create mode 100644 frontend/src/components/PublishPlanModal.vue diff --git a/frontend/src/components/CommunityFeedPanel.vue b/frontend/src/components/CommunityFeedPanel.vue index e941e8c..4b9db11 100644 --- a/frontend/src/components/CommunityFeedPanel.vue +++ b/frontend/src/components/CommunityFeedPanel.vue @@ -15,6 +15,17 @@ >{{ f.label }}
+ +
+ +
+
+ + +
@@ -91,6 +110,7 @@ import { ref, onMounted } from 'vue' import { useCommunityStore } from '../stores/community' import CommunityPostCard from './CommunityPostCard.vue' +import PublishPlanModal from './PublishPlanModal.vue' const emit = defineEmits<{ 'plan-forked': [payload: { plan_id: number; week_start: string }] @@ -99,6 +119,7 @@ const emit = defineEmits<{ const store = useCommunityStore() const activeFilter = ref('all') +const showPublishPlan = ref(false) const filters = [ { id: 'all', label: 'All' }, @@ -160,6 +181,11 @@ async function handleFork(slug: string) { } } +function onPlanPublished(_payload: { slug: string }) { + showPublishPlan.value = false + store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value) +} + onMounted(async () => { if (store.posts.length === 0) { await store.fetchPosts() @@ -182,6 +208,14 @@ onMounted(async () => { border-bottom: none; } +.action-row { + padding: var(--spacing-xs) 0; +} + +.share-plan-btn { + font-size: var(--font-size-xs); +} + /* Loading skeletons */ .skeleton-card { background: var(--color-bg-card); diff --git a/frontend/src/components/PublishOutcomeModal.vue b/frontend/src/components/PublishOutcomeModal.vue new file mode 100644 index 0000000..b1810d1 --- /dev/null +++ b/frontend/src/components/PublishOutcomeModal.vue @@ -0,0 +1,363 @@ +