magpie/app/api/endpoints/opportunities.py
pyr0ball 2822d36bad feat(#9): opportunities queue — manual posting workflow UI and API
Adds the full signal-to-post pipeline for non-automated opportunities:
- SQLite migration 007: opportunities table (platform, community, thread_url,
  draft title/body, post_type, status, campaign_id, dismiss_note)
- FastAPI endpoints: GET/POST /opportunities, GET/PATCH /{id}, /{id}/approve,
  /{id}/mark-posted, /{id}/dismiss
- approve() returns auto_post_ready (Reddit) or manual_handoff (Lemmy/LinkedIn/etc)
  with clipboard-ready draft and instructions
- OpportunitiesView.vue: status-filtered queue, slide-over detail panel with
  inline draft editor, approve/dismiss actions, manual handoff copy+open flow
- Opportunities now default landing route; nav link added
- MCP tools: list_opportunities, create_opportunity, approve_opportunity,
  dismiss_opportunity, update_opportunity

Closes #9
2026-04-21 16:51:34 -07:00

150 lines
4.7 KiB
Python

from __future__ import annotations
import asyncio
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.core.config import get_settings
from app.db.store import Store
router = APIRouter(prefix="/opportunities", tags=["opportunities"])
VALID_STATUSES = {"pending_review", "approved", "posted", "manual_posted", "dismissed"}
def _get_store() -> Store:
return Store(get_settings().db_path)
def _in_thread(fn):
store = _get_store()
try:
return fn(store)
finally:
store.close()
# ------------------------------------------------------------------ #
# Schemas
# ------------------------------------------------------------------ #
class OpportunityCreate(BaseModel):
platform: str = "reddit"
community: str
thread_url: str
thread_title: str | None = None
thread_body: str | None = None
signal_reason: str | None = None
product: str | None = None
draft_title: str | None = None
draft_body: str = ""
post_type: Literal["reply_to_thread", "new_post"] = "reply_to_thread"
campaign_id: int | None = None
class OpportunityUpdate(BaseModel):
draft_title: str | None = None
draft_body: str | None = None
signal_reason: str | None = None
product: str | None = None
status: str | None = None
campaign_id: int | None = None
class DismissBody(BaseModel):
note: str | None = None
# ------------------------------------------------------------------ #
# Routes
# ------------------------------------------------------------------ #
@router.get("")
async def list_opportunities(status: str | None = None):
if status and status not in VALID_STATUSES:
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_STATUSES)}")
return await asyncio.to_thread(_in_thread, lambda s: s.list_opportunities(status))
@router.post("", status_code=201)
async def create_opportunity(body: OpportunityCreate):
return await asyncio.to_thread(
_in_thread,
lambda s: s.create_opportunity(**body.model_dump()),
)
@router.get("/{opportunity_id}")
async def get_opportunity(opportunity_id: int):
result = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
if result is None:
raise HTTPException(404, "Opportunity not found")
return result
@router.patch("/{opportunity_id}")
async def update_opportunity(opportunity_id: int, body: OpportunityUpdate):
updates = {k: v for k, v in body.model_dump().items() if v is not None}
if "status" in updates and updates["status"] not in VALID_STATUSES:
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_STATUSES)}")
result = await asyncio.to_thread(
_in_thread, lambda s: s.update_opportunity(opportunity_id, **updates)
)
if result is None:
raise HTTPException(404, "Opportunity not found")
return result
@router.post("/{opportunity_id}/approve")
async def approve_opportunity(opportunity_id: int):
"""Mark as approved. For Reddit opportunities, returns auto-post instructions.
For other platforms, returns a manual handoff payload."""
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
if opp is None:
raise HTTPException(404, "Opportunity not found")
updated = await asyncio.to_thread(
_in_thread, lambda s: s.approve_opportunity(opportunity_id)
)
if opp["platform"] == "reddit":
return {
"type": "auto_post_ready",
"opportunity": updated,
"instructions": "Use trigger_sub_post with the linked campaign, or fire manually via post.py.",
}
return {
"type": "manual_handoff",
"opportunity": updated,
"draft_body": opp["draft_body"],
"thread_url": opp["thread_url"],
"instructions": (
f"Copy the draft and reply to this thread manually ({opp['platform']}). "
"Mark as manual_posted when done."
),
}
@router.post("/{opportunity_id}/mark-posted")
async def mark_posted(opportunity_id: int, manual: bool = False):
"""Record that a post was successfully made (auto or manual)."""
status = "manual_posted" if manual else "posted"
result = await asyncio.to_thread(
_in_thread, lambda s: s.update_opportunity(opportunity_id, status=status)
)
if result is None:
raise HTTPException(404, "Opportunity not found")
return result
@router.post("/{opportunity_id}/dismiss")
async def dismiss_opportunity(opportunity_id: int, body: DismissBody):
result = await asyncio.to_thread(
_in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note)
)
if result is None:
raise HTTPException(404, "Opportunity not found")
return result