diff --git a/backend/app/crud.py b/backend/app/crud.py index 65884bf..b7e491c 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -15,6 +15,7 @@ def create_item( captured_at: datetime, discord_message_id: str | None, sender_id: str | None = None, + direction: str = "inbound", ) -> tuple[Item, bool]: if discord_message_id is not None: existing = session.scalar( @@ -29,6 +30,7 @@ def create_item( captured_at=captured_at, discord_message_id=discord_message_id, sender_id=sender_id, + direction=direction, stage="new", ) session.add(item) @@ -66,8 +68,11 @@ def update_item(session: Session, item_id: int, **fields) -> Item | None: new_type = fields.get("type", item.type) new_stage = fields.get("stage", item.stage) - if "stage" in fields and not is_valid_stage(new_type, new_stage): - raise ValueError(f"'{new_stage}' is not a valid stage for type '{new_type}'") + new_direction = fields.get("direction", item.direction) + if "stage" in fields and not is_valid_stage(new_type, new_stage, new_direction): + raise ValueError( + f"'{new_stage}' is not a valid stage for type '{new_type}' direction '{new_direction}'" + ) for key, value in fields.items(): setattr(item, key, value) diff --git a/backend/app/models.py b/backend/app/models.py index 2ffb9d5..8f32b2e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -14,6 +14,7 @@ class Item(Base): sender_id: Mapped[str | None] = mapped_column(String, nullable=True) type: Mapped[str | None] = mapped_column(String, nullable=True) stage: Mapped[str] = mapped_column(String, nullable=False, default="new") + direction: Mapped[str] = mapped_column(String, nullable=False, default="inbound") notes: Mapped[str | None] = mapped_column(Text, nullable=True) follow_up_date: Mapped[date | None] = mapped_column(Date, nullable=True) discord_message_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 43a64bb..319e6ca 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -8,6 +8,7 @@ class ItemCreate(BaseModel): captured_at: datetime discord_message_id: str | None = None sender_id: str | None = None + direction: str = "inbound" class ItemUpdate(BaseModel): @@ -28,6 +29,7 @@ class ItemOut(BaseModel): sender_id: str | None type: str | None stage: str + direction: str notes: str | None follow_up_date: date | None discord_message_id: str | None diff --git a/backend/app/stages.py b/backend/app/stages.py index 5e9b223..3d2f7f0 100644 --- a/backend/app/stages.py +++ b/backend/app/stages.py @@ -1,8 +1,14 @@ DONATION_STAGES = ["new", "replied", "pickup_scheduled", "picked_up", "logged_in_bookmark"] OTHER_STAGES = ["new", "done"] +OUTBOUND_STAGES = [ + "new", "reviewed", "outreach_drafted", "outreach_sent", + "replied", "pickup_scheduled", "picked_up", "logged_in_bookmark", +] -def valid_stages_for(item_type: str | None) -> list[str]: +def valid_stages_for(item_type: str | None, direction: str = "inbound") -> list[str]: + if direction == "outbound": + return OUTBOUND_STAGES if item_type == "donation": return DONATION_STAGES if item_type == "other": @@ -10,5 +16,5 @@ def valid_stages_for(item_type: str | None) -> list[str]: return ["new"] -def is_valid_stage(item_type: str | None, stage: str) -> bool: - return stage in valid_stages_for(item_type) +def is_valid_stage(item_type: str | None, stage: str, direction: str = "inbound") -> bool: + return stage in valid_stages_for(item_type, direction) diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py index 97b73b9..eba656e 100644 --- a/backend/tests/test_crud.py +++ b/backend/tests/test_crud.py @@ -61,3 +61,37 @@ def test_update_item_rejects_invalid_stage_for_type(db_session): def test_update_item_returns_none_for_missing_id(db_session): assert crud.update_item(db_session, 9999, notes="x") is None + + +def test_create_item_defaults_direction_to_inbound(db_session): + item, _ = crud.create_item( + db_session, modality="bh_email", raw_content="x", captured_at=_now(), + discord_message_id="dir-1", + ) + assert item.direction == "inbound" + + +def test_create_item_accepts_outbound_direction(db_session): + item, _ = crud.create_item( + db_session, modality="lead_found", raw_content="found post", captured_at=_now(), + discord_message_id="dir-2", direction="outbound", + ) + assert item.direction == "outbound" + + +def test_update_item_validates_stage_against_outbound_direction(db_session): + item, _ = crud.create_item( + db_session, modality="lead_found", raw_content="x", captured_at=_now(), + discord_message_id="dir-3", direction="outbound", + ) + updated = crud.update_item(db_session, item.id, stage="outreach_sent") + assert updated.stage == "outreach_sent" + + +def test_update_item_rejects_inbound_only_stage_for_outbound_item(db_session): + item, _ = crud.create_item( + db_session, modality="lead_found", raw_content="x", captured_at=_now(), + discord_message_id="dir-4", direction="outbound", + ) + with pytest.raises(ValueError): + crud.update_item(db_session, item.id, stage="done") diff --git a/backend/tests/test_items_api.py b/backend/tests/test_items_api.py index 685b93f..ced32e0 100644 --- a/backend/tests/test_items_api.py +++ b/backend/tests/test_items_api.py @@ -68,3 +68,21 @@ def test_patch_invalid_stage_returns_422(client): def test_get_missing_item_returns_404(client): resp = client.get("/items/999999") assert resp.status_code == 404 + + +def test_create_item_with_outbound_direction(client): + resp = client.post("/items", json={ + "modality": "lead_found", "raw_content": "found on Nextdoor", + "captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "out-1", + "direction": "outbound", + }) + assert resp.status_code == 201 + assert resp.json()["direction"] == "outbound" + + +def test_create_item_direction_defaults_to_inbound(client): + resp = client.post("/items", json={ + "modality": "voice", "raw_content": "call", + "captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "out-2", + }) + assert resp.json()["direction"] == "inbound" diff --git a/backend/tests/test_stages.py b/backend/tests/test_stages.py index 0e934db..d2136be 100644 --- a/backend/tests/test_stages.py +++ b/backend/tests/test_stages.py @@ -33,3 +33,24 @@ def test_is_valid_stage_false_for_mismatched_type(): def test_is_valid_stage_false_for_unknown_value(): assert stages.is_valid_stage("donation", "archived") is False + + +def test_outbound_stages_in_order(): + assert stages.OUTBOUND_STAGES == [ + "new", "reviewed", "outreach_drafted", "outreach_sent", + "replied", "pickup_scheduled", "picked_up", "logged_in_bookmark", + ] + + +def test_valid_stages_for_outbound_ignores_type(): + assert stages.valid_stages_for("donation", direction="outbound") == stages.OUTBOUND_STAGES + assert stages.valid_stages_for(None, direction="outbound") == stages.OUTBOUND_STAGES + + +def test_valid_stages_for_inbound_unchanged(): + assert stages.valid_stages_for("donation") == stages.DONATION_STAGES + + +def test_is_valid_stage_outbound_accepts_outreach_stages(): + assert stages.is_valid_stage("donation", "outreach_sent", direction="outbound") is True + assert stages.is_valid_stage("donation", "outreach_sent", direction="inbound") is False diff --git a/bot/chorus_bot/backend_client.py b/bot/chorus_bot/backend_client.py index 96bc6d8..6932136 100644 --- a/bot/chorus_bot/backend_client.py +++ b/bot/chorus_bot/backend_client.py @@ -14,6 +14,7 @@ class BackendClient: captured_at: datetime, discord_message_id: str | None, sender_id: str | None = None, + direction: str = "inbound", ) -> dict: payload = { "modality": modality, @@ -21,6 +22,7 @@ class BackendClient: "captured_at": captured_at.isoformat(), "discord_message_id": discord_message_id, "sender_id": sender_id, + "direction": direction, } async with httpx.AsyncClient() as client: resp = await client.post(f"{self._base_url}/items", json=payload, timeout=10) diff --git a/bot/chorus_bot/cogs/manual_share.py b/bot/chorus_bot/cogs/manual_share.py index 8741d2a..f5e9c90 100644 --- a/bot/chorus_bot/cogs/manual_share.py +++ b/bot/chorus_bot/cogs/manual_share.py @@ -1,6 +1,6 @@ import discord from discord.ext import commands -from chorus_bot.modality_map import modality_for_channel +from chorus_bot.modality_map import modality_for_channel, direction_for_modality from chorus_bot.ping import build_ping_embed @@ -32,6 +32,7 @@ class ManualShareCog(commands.Cog): snippet=snippet, raw_content=raw_content, captured_at=message.created_at, + direction=direction_for_modality(modality), ) inbox = self.bot.get_channel(self.config.inbox_channel_id) diff --git a/bot/chorus_bot/cogs/thread_ingest.py b/bot/chorus_bot/cogs/thread_ingest.py index 717c608..6278618 100644 --- a/bot/chorus_bot/cogs/thread_ingest.py +++ b/bot/chorus_bot/cogs/thread_ingest.py @@ -31,6 +31,7 @@ class ThreadIngestCog(commands.Cog): captured_at=parsed["captured_at"], discord_message_id=str(thread.id), sender_id=parsed["sender_id"], + direction=parsed["direction"], ) await thread.send( "Logged in Chorus - open the app to fill in details and track this one." diff --git a/bot/chorus_bot/modality_map.py b/bot/chorus_bot/modality_map.py index a4421ee..083476a 100644 --- a/bot/chorus_bot/modality_map.py +++ b/bot/chorus_bot/modality_map.py @@ -3,8 +3,15 @@ CHANNEL_TO_MODALITY = { "voice": "voice", "fb-messenger": "fb_messenger", "nd-messenger": "nd_messenger", + "leads-found": "lead_found", } +OUTBOUND_MODALITIES = {"lead_found", "magpie_lead"} + def modality_for_channel(channel_name: str) -> str | None: return CHANNEL_TO_MODALITY.get(channel_name) + + +def direction_for_modality(modality: str) -> str: + return "outbound" if modality in OUTBOUND_MODALITIES else "inbound" diff --git a/bot/chorus_bot/ping.py b/bot/chorus_bot/ping.py index e6ec946..8a1dd4f 100644 --- a/bot/chorus_bot/ping.py +++ b/bot/chorus_bot/ping.py @@ -9,6 +9,8 @@ MODALITY_LABELS = { "voice": "Voice", "fb_messenger": "FB Messenger", "nd_messenger": "ND Messenger", + "lead_found": "Found Lead", + "magpie_lead": "Magpie Lead", } # Discord's API rejects embed field values of length 0 (min 1, max 1024 chars). @@ -34,7 +36,7 @@ def _safe_field_value(value: str) -> str: def build_ping_embed( *, modality: str, sender_id: str | None, snippet: str, raw_content: str, - captured_at: datetime, + captured_at: datetime, direction: str = "inbound", ) -> discord.Embed: label = MODALITY_LABELS.get(modality, modality) embed = discord.Embed( @@ -42,6 +44,7 @@ def build_ping_embed( description=snippet, ) embed.add_field(name="modality", value=_safe_field_value(modality), inline=True) + embed.add_field(name="direction", value=_safe_field_value(direction), inline=True) embed.add_field(name="sender_id", value=_safe_field_value(sender_id or ""), inline=True) embed.add_field( name="captured_at", value=_safe_field_value(captured_at.isoformat()), inline=True, @@ -64,9 +67,12 @@ def parse_ping_embed(embed: discord.Embed) -> dict: if raw_content == _EMPTY_FIELD_SENTINEL: raw_content = "" + direction = values.get("direction") or "inbound" + return { "modality": values["modality"], "sender_id": sender_id, "raw_content": raw_content, "captured_at": datetime.fromisoformat(values["captured_at"]), + "direction": direction, } diff --git a/bot/tests/test_manual_share.py b/bot/tests/test_manual_share.py new file mode 100644 index 0000000..3ca7747 --- /dev/null +++ b/bot/tests/test_manual_share.py @@ -0,0 +1,51 @@ +from unittest.mock import AsyncMock, MagicMock +import pytest +import discord +from chorus_bot.cogs.manual_share import ManualShareCog +from chorus_bot.ping import parse_ping_embed + + +class _FakeConfig: + inbox_channel_id = 111 + triage_role_id = 222 + + +def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999): + message = MagicMock(spec=discord.Message) + message.author.bot = author_bot + message.channel.id = channel_id + message.channel.name = channel_name + message.content = content + message.created_at = __import__("datetime").datetime(2026, 7, 13, 7, 0, 0) + return message + + +@pytest.mark.asyncio +async def test_leads_found_channel_forwards_with_outbound_direction(): + bot = MagicMock() + inbox = AsyncMock() + bot.get_channel.return_value = inbox + cog = ManualShareCog(bot, _FakeConfig()) + + message = _make_message(channel_name="leads-found", content="Neighbor posting about book giveaway") + await cog.on_message(message) + + sent_embed = inbox.send.call_args.kwargs["embed"] + parsed = parse_ping_embed(sent_embed) + assert parsed["direction"] == "outbound" + assert parsed["modality"] == "lead_found" + + +@pytest.mark.asyncio +async def test_existing_modality_channel_still_forwards_with_inbound_direction(): + bot = MagicMock() + inbox = AsyncMock() + bot.get_channel.return_value = inbox + cog = ManualShareCog(bot, _FakeConfig()) + + message = _make_message(channel_name="voice", content="call about donation") + await cog.on_message(message) + + sent_embed = inbox.send.call_args.kwargs["embed"] + parsed = parse_ping_embed(sent_embed) + assert parsed["direction"] == "inbound" diff --git a/bot/tests/test_modality_map.py b/bot/tests/test_modality_map.py index 9ae9e06..4b9537c 100644 --- a/bot/tests/test_modality_map.py +++ b/bot/tests/test_modality_map.py @@ -15,3 +15,17 @@ def test_unknown_channel_returns_none(): def test_inbox_and_general_channels_are_not_mapped(): assert modality_map.modality_for_channel("inbox") is None assert modality_map.modality_for_channel("general") is None + + +def test_leads_found_channel_maps_to_lead_found_modality(): + assert modality_map.modality_for_channel("leads-found") == "lead_found" + + +def test_direction_for_modality_outbound_for_leads(): + assert modality_map.direction_for_modality("lead_found") == "outbound" + assert modality_map.direction_for_modality("magpie_lead") == "outbound" + + +def test_direction_for_modality_inbound_for_everything_else(): + assert modality_map.direction_for_modality("bh_email") == "inbound" + assert modality_map.direction_for_modality("voice") == "inbound" diff --git a/bot/tests/test_ping.py b/bot/tests/test_ping.py index e668117..20c0289 100644 --- a/bot/tests/test_ping.py +++ b/bot/tests/test_ping.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +import discord from chorus_bot import ping @@ -90,3 +91,36 @@ def test_build_ping_embed_truncates_long_raw_content(): raw_content_field = next(f for f in embed.fields if f.name == "raw_content") assert len(raw_content_field.value) <= 1024 assert "truncated" in raw_content_field.value + + +def test_build_and_parse_round_trip_includes_direction(): + captured_at = datetime(2026, 7, 13, 7, 0, 0, tzinfo=timezone.utc) + embed = ping.build_ping_embed( + modality="lead_found", sender_id=None, snippet="found post", + raw_content="Someone posted about donating books on Nextdoor", + captured_at=captured_at, direction="outbound", + ) + parsed = ping.parse_ping_embed(embed) + assert parsed["direction"] == "outbound" + + +def test_build_ping_embed_direction_defaults_to_inbound(): + embed = ping.build_ping_embed( + modality="bh_email", sender_id="jane@example.com", snippet="hi", + raw_content="hi", captured_at=datetime(2026, 7, 13, 7, 0, 0, tzinfo=timezone.utc), + ) + parsed = ping.parse_ping_embed(embed) + assert parsed["direction"] == "inbound" + + +def test_parse_ping_embed_defaults_direction_when_field_missing(): + """Regression guard: embeds built before this change have no direction + field at all -- parsing one must not raise and must default to inbound.""" + embed = discord.Embed(title="bh_email", description="hi") + embed.add_field(name="modality", value="bh_email", inline=True) + embed.add_field(name="sender_id", value="jane@example.com", inline=True) + embed.add_field(name="captured_at", value="2026-07-13T07:00:00+00:00", inline=True) + embed.add_field(name="raw_content", value="hi", inline=False) + + parsed = ping.parse_ping_embed(embed) + assert parsed["direction"] == "inbound" diff --git a/bot/tests/test_thread_ingest.py b/bot/tests/test_thread_ingest.py new file mode 100644 index 0000000..05b439d --- /dev/null +++ b/bot/tests/test_thread_ingest.py @@ -0,0 +1,37 @@ +from unittest.mock import AsyncMock, MagicMock +import pytest +import discord +from chorus_bot.cogs.thread_ingest import ThreadIngestCog +from chorus_bot.ping import build_ping_embed + + +class _FakeConfig: + inbox_channel_id = 111 + + +@pytest.mark.asyncio +async def test_thread_create_passes_direction_to_backend_client(): + bot = MagicMock() + backend_client = AsyncMock() + cog = ThreadIngestCog(bot, _FakeConfig(), backend_client) + + embed = build_ping_embed( + modality="lead_found", sender_id=None, snippet="found post", + raw_content="Someone posting about book donation", + captured_at=__import__("datetime").datetime(2026, 7, 13, 7, 0, 0, + tzinfo=__import__("datetime").timezone.utc), + direction="outbound", + ) + parent_message = MagicMock() + parent_message.embeds = [embed] + + thread = MagicMock(spec=discord.Thread) + thread.id = 555 + thread.parent_id = 111 + thread.parent.fetch_message = AsyncMock(return_value=parent_message) + thread.send = AsyncMock() + + await cog.on_thread_create(thread) + + backend_client.create_item.assert_awaited_once() + assert backend_client.create_item.call_args.kwargs["direction"] == "outbound" diff --git a/frontend/src/components/ItemModal.vue b/frontend/src/components/ItemModal.vue index a1e406a..2be0896 100644 --- a/frontend/src/components/ItemModal.vue +++ b/frontend/src/components/ItemModal.vue @@ -13,6 +13,10 @@ const followUpDate = ref('') const DONATION_STAGES = ['new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark'] const OTHER_STAGES = ['new', 'done'] +const OUTBOUND_STAGES = [ + 'new', 'reviewed', 'outreach_drafted', 'outreach_sent', + 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark', +] watch(() => props.item, (item) => { if (!item) return @@ -24,6 +28,7 @@ watch(() => props.item, (item) => { }, { immediate: true }) function stagesForType() { + if (props.item?.direction === 'outbound') return OUTBOUND_STAGES if (type.value === 'donation') return DONATION_STAGES if (type.value === 'other') return OTHER_STAGES return ['new'] diff --git a/frontend/tests/ItemModal.spec.js b/frontend/tests/ItemModal.spec.js index 855f68b..c3131a6 100644 --- a/frontend/tests/ItemModal.spec.js +++ b/frontend/tests/ItemModal.spec.js @@ -16,6 +16,7 @@ function makeItem(overrides = {}) { stage: 'new', notes: '', follow_up_date: '', + direction: 'inbound', ...overrides, } } @@ -53,4 +54,28 @@ describe('ItemModal', () => { expect(wrapper.findAll('select')[1].element.value).toBe('new') }) + + it('offers outbound stages when item.direction is outbound, regardless of type', () => { + const wrapper = mount(ItemModal, { + props: { item: makeItem({ direction: 'outbound', type: 'donation', stage: 'new' }) }, + }) + const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value) + expect(options).toEqual([ + 'new', 'reviewed', 'outreach_drafted', 'outreach_sent', + 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark', + ]) + }) + + it('does not reset an outbound item stage when type changes', async () => { + const wrapper = mount(ItemModal, { + props: { item: makeItem({ direction: 'outbound', type: 'donation', stage: 'outreach_sent' }) }, + }) + const stageSelect = wrapper.findAll('select')[1] + expect(stageSelect.element.value).toBe('outreach_sent') + + const typeSelect = wrapper.findAll('select')[0] + await typeSelect.setValue('other') + + expect(wrapper.findAll('select')[1].element.value).toBe('outreach_sent') + }) })