diff --git a/.gitignore b/.gitignore index cb2f90d..3018155 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ debug_*.png *.db *.db-wal *.db-shm +data/ # IDE .vscode/ diff --git a/app/api/endpoints/opportunities.py b/app/api/endpoints/opportunities.py new file mode 100644 index 0000000..2bb0f35 --- /dev/null +++ b/app/api/endpoints/opportunities.py @@ -0,0 +1,150 @@ +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 diff --git a/app/db/migrations/007_opportunities.sql b/app/db/migrations/007_opportunities.sql new file mode 100644 index 0000000..063cce2 --- /dev/null +++ b/app/db/migrations/007_opportunities.sql @@ -0,0 +1,23 @@ +-- Opportunities: flagged threads/posts queued for human review before posting. +-- Covers both auto-post (Reddit via Playwright) and manual handoff (Lemmy, LinkedIn, etc.) +CREATE TABLE IF NOT EXISTS opportunities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL DEFAULT 'reddit', + community TEXT NOT NULL, -- sub name, lemmy community, etc. + thread_url TEXT NOT NULL, + thread_title TEXT, + thread_body TEXT, -- snippet of original post for context + signal_reason TEXT, -- why this was flagged + product TEXT, -- peregrine, kiwi, snipe, circuitforge + draft_title TEXT, -- for new_post type; NULL for replies + draft_body TEXT NOT NULL DEFAULT '', + post_type TEXT NOT NULL DEFAULT 'reply_to_thread', -- reply_to_thread | new_post + status TEXT NOT NULL DEFAULT 'pending_review', -- pending_review | approved | posted | manual_posted | dismissed + campaign_id INTEGER REFERENCES campaigns(id) ON DELETE SET NULL, + dismiss_note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_opportunities_status ON opportunities(status); +CREATE INDEX IF NOT EXISTS idx_opportunities_platform ON opportunities(platform, community); diff --git a/frontend/src/components/OpportunitiesView.vue b/frontend/src/components/OpportunitiesView.vue new file mode 100644 index 0000000..5d7fb9e --- /dev/null +++ b/frontend/src/components/OpportunitiesView.vue @@ -0,0 +1,523 @@ + + + + Opportunities + + + All + Pending review + Approved + Posted + Manual posted + Dismissed + + + Add + + + + Loading... + + No opportunities{{ filterStatus ? ` with status "${filterStatus}"` : '' }}. + + + + + + {{ opp.platform }} + {{ opp.community }} + {{ opp.post_type === 'reply_to_thread' ? 'reply' : 'new post' }} + {{ opp.product }} + + {{ opp.thread_title || opp.thread_url }} + {{ opp.signal_reason }} + + + + + + + + ✕ + + + + {{ selected.platform }} + {{ selected.community }} + {{ selected.post_type === 'reply_to_thread' ? 'reply' : 'new post' }} + + {{ selected.status.replace('_', ' ') }} + + + + + Thread + + {{ selected.thread_title || selected.thread_url }} + + {{ selected.thread_body }} + + + + Why flagged + {{ selected.signal_reason }} + + + + + Draft {{ selected.post_type === 'new_post' ? 'post' : 'reply' }} + + + + Save draft + + + + + + + Approve + + + Dismiss + + + + + + Manual handoff + + 📋 Copy draft + 🔗 Open thread + + ✓ Mark as posted + + + Copied to clipboard + + + + + Ready to post + Use trigger_sub_post from the Campaigns view, or mark as posted manually if you handled it. + + ✓ Mark as posted + + + + + + + + + ✕ + Add opportunity + + + Thread URL + + + Platform + + reddit + lemmy + linkedin + other + + + Community + + + Post type + + Reply to thread + New post + + + Product + + — any — + peregrine + kiwi + snipe + circuitforge + + + Thread title (optional) + + + Why flagged + + + Draft + + + + + + + Add to queue + + Cancel + + + + + + + + +
{{ selected.thread_body }}
{{ selected.signal_reason }}
Use trigger_sub_post from the Campaigns view, or mark as posted manually if you handled it.