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"