FastAPI backend (SQLite + APScheduler), Vue 3 frontend, MCP server for Claude integration, and Docker Compose stack. Includes campaign data model (campaigns → variants → subs), post history, sub rules, and Playwright-based Reddit posting layer migrated from claude-bridge/reddit-poster. Also seeds legacy campaigns (6) and sub rules (14) from reddit-poster history. Closes #1 (scaffold), resolves migration from claude-bridge.
184 lines
5.7 KiB
Python
184 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.config import get_settings
|
|
from app.db.store import Store
|
|
from app.services.poster import run_campaign
|
|
from app.services.scheduler import remove_campaign as scheduler_remove, sync_campaign
|
|
|
|
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
|
|
|
|
|
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 CampaignCreate(BaseModel):
|
|
name: str
|
|
product: str
|
|
platform: str = "reddit"
|
|
cron_schedule: str | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
class CampaignUpdate(BaseModel):
|
|
name: str | None = None
|
|
product: str | None = None
|
|
cron_schedule: str | None = None
|
|
active: bool | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
class VariantCreate(BaseModel):
|
|
sub_pattern: str = "*"
|
|
title: str
|
|
body: str
|
|
flair: str | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
class VariantUpdate(BaseModel):
|
|
sub_pattern: str | None = None
|
|
title: str | None = None
|
|
body: str | None = None
|
|
flair: str | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
class SubEntry(BaseModel):
|
|
sub: str
|
|
sort_order: int = 0
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Campaign CRUD
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@router.get("")
|
|
async def list_campaigns(active_only: bool = False):
|
|
return await asyncio.to_thread(_in_thread, lambda s: s.list_campaigns(active_only))
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_campaign(body: CampaignCreate):
|
|
campaign = await asyncio.to_thread(
|
|
_in_thread,
|
|
lambda s: s.create_campaign(**body.model_dump()),
|
|
)
|
|
sync_campaign(campaign)
|
|
return campaign
|
|
|
|
|
|
@router.get("/{campaign_id}")
|
|
async def get_campaign(campaign_id: int):
|
|
result = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id))
|
|
if result is None:
|
|
raise HTTPException(404, "Campaign not found")
|
|
return result
|
|
|
|
|
|
@router.patch("/{campaign_id}")
|
|
async def update_campaign(campaign_id: int, body: CampaignUpdate):
|
|
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
if "active" in updates:
|
|
updates["active"] = 1 if updates["active"] else 0
|
|
result = await asyncio.to_thread(_in_thread, lambda s: s.update_campaign(campaign_id, **updates))
|
|
if result is None:
|
|
raise HTTPException(404, "Campaign not found")
|
|
sync_campaign(result)
|
|
return result
|
|
|
|
|
|
@router.delete("/{campaign_id}", status_code=204)
|
|
async def delete_campaign(campaign_id: int):
|
|
ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_campaign(campaign_id))
|
|
if not ok:
|
|
raise HTTPException(404, "Campaign not found")
|
|
scheduler_remove(campaign_id)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Manual trigger
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@router.post("/{campaign_id}/trigger")
|
|
async def trigger_campaign(campaign_id: int):
|
|
"""Manually fire a campaign to all its configured subs."""
|
|
campaign = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id))
|
|
if campaign is None:
|
|
raise HTTPException(404, "Campaign not found")
|
|
results = await run_campaign(campaign_id, triggered_by="manual")
|
|
return {"campaign_id": campaign_id, "results": results}
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Variants
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@router.get("/{campaign_id}/variants")
|
|
async def list_variants(campaign_id: int):
|
|
return await asyncio.to_thread(_in_thread, lambda s: s.list_variants(campaign_id))
|
|
|
|
|
|
@router.post("/{campaign_id}/variants", status_code=201)
|
|
async def create_variant(campaign_id: int, body: VariantCreate):
|
|
return await asyncio.to_thread(
|
|
_in_thread,
|
|
lambda s: s.create_variant(campaign_id=campaign_id, **body.model_dump()),
|
|
)
|
|
|
|
|
|
@router.patch("/{campaign_id}/variants/{variant_id}")
|
|
async def update_variant(campaign_id: int, variant_id: int, body: VariantUpdate):
|
|
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
result = await asyncio.to_thread(_in_thread, lambda s: s.update_variant(variant_id, **updates))
|
|
if result is None:
|
|
raise HTTPException(404, "Variant not found")
|
|
return result
|
|
|
|
|
|
@router.delete("/{campaign_id}/variants/{variant_id}", status_code=204)
|
|
async def delete_variant(campaign_id: int, variant_id: int):
|
|
ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_variant(variant_id))
|
|
if not ok:
|
|
raise HTTPException(404, "Variant not found")
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Subs
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@router.get("/{campaign_id}/subs")
|
|
async def list_campaign_subs(campaign_id: int):
|
|
return await asyncio.to_thread(_in_thread, lambda s: s.list_campaign_subs(campaign_id))
|
|
|
|
|
|
@router.post("/{campaign_id}/subs", status_code=201)
|
|
async def add_campaign_sub(campaign_id: int, body: SubEntry):
|
|
return await asyncio.to_thread(
|
|
_in_thread,
|
|
lambda s: s.add_campaign_sub(campaign_id, body.sub, body.sort_order),
|
|
)
|
|
|
|
|
|
@router.delete("/{campaign_id}/subs/{sub}", status_code=204)
|
|
async def remove_campaign_sub(campaign_id: int, sub: str):
|
|
ok = await asyncio.to_thread(_in_thread, lambda s: s.remove_campaign_sub(campaign_id, sub))
|
|
if not ok:
|
|
raise HTTPException(404, "Sub not found in campaign")
|