feat: add direction field and outbound stage set to Chorus items

This commit is contained in:
pyr0ball 2026-07-13 19:01:01 -07:00
parent 68d5cc91ab
commit 4dbda85ccd
7 changed files with 92 additions and 5 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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"

View file

@ -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