From 4dbda85ccdd001a31bcfd193e09acd53c1813d6b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 19:01:01 -0700 Subject: [PATCH] feat: add direction field and outbound stage set to Chorus items --- backend/app/crud.py | 9 +++++++-- backend/app/models.py | 1 + backend/app/schemas.py | 2 ++ backend/app/stages.py | 12 +++++++++--- backend/tests/test_crud.py | 34 +++++++++++++++++++++++++++++++++ backend/tests/test_items_api.py | 18 +++++++++++++++++ backend/tests/test_stages.py | 21 ++++++++++++++++++++ 7 files changed, 92 insertions(+), 5 deletions(-) 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