Source Retire lifecycle (Phase 1: status + content_visible, active mirrored)

Per Codex's plan — introduce a lifecycle without a risky "change the source of
truth everywhere" moment.

* Schema: sources.status (active|paused|retired) + content_visible; migration
  backfills status from active (active=1→active, else paused), content_visible=1.
* `active` is kept as a SYNCED MIRROR: status active→active=1, paused/retired→0,
  so the scheduler/CLI/legacy code keep working unchanged.
* Retire stops polling but keeps articles visible (non-destructive). Hiding is a
  separate, reversible lever: content_visible=0 drops a source's articles from
  the public feed + brief (read AND build), behind a confirm. Personal saved/
  history are untouched.
* API: /sources/{id}/status (validates, mirrors active) + /visibility, replacing
  /active. source_health returns status + content_visible.
* Admin: status column (active/paused/retired + "hidden"), Retired filter,
  Pause/Resume · Retire/Restore · Hide/Show actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 09:58:15 -04:00
parent ba92c0a04b
commit 9ed817c051
6 changed files with 124 additions and 49 deletions
+32 -8
View File
@@ -386,8 +386,12 @@ class FeedbackReplyBody(BaseModel):
html: str = "" # raw editor HTML — sanitized server-side before use
class SourceActiveBody(BaseModel):
active: bool = True
class SourceStatusBody(BaseModel):
status: str = "active" # active | paused | retired
class SourceVisibilityBody(BaseModel):
visible: bool = True
class SourceReviewBody(BaseModel):
@@ -882,17 +886,37 @@ def create_app() -> FastAPI:
).fetchone()
return {"ok": True, "reply": dict(reply)}
@app.post("/api/admin/sources/{sid}/active")
def admin_source_active(sid: int, body: SourceActiveBody, request: Request) -> dict:
# Pause/resume a feed. Pausing only stops future polling — its already-
# accepted articles stay live (the feed query doesn't filter on active).
@app.post("/api/admin/sources/{sid}/status")
def admin_source_status(sid: int, body: SourceStatusBody, request: Request) -> dict:
# Lifecycle: active | paused | retired. Stops/resumes polling only —
# existing articles stay visible (use /visibility to hide). `active` is
# kept mirrored so the scheduler/CLI keep working off the legacy flag.
if body.status not in ("active", "paused", "retired"):
raise HTTPException(status_code=422, detail="invalid status")
active = 1 if body.status == "active" else 0
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute("UPDATE sources SET active = ? WHERE id = ?", (1 if body.active else 0, sid))
cur = conn.execute(
"UPDATE sources SET status = ?, active = ? WHERE id = ?", (body.status, active, sid)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
return {"ok": True, "active": body.active}
return {"ok": True, "status": body.status, "active": active}
@app.post("/api/admin/sources/{sid}/visibility")
def admin_source_visibility(sid: int, body: SourceVisibilityBody, request: Request) -> dict:
# Pull a source's existing articles out of (or back into) the public feed
# and brief. Reversible; never deletes anything.
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute(
"UPDATE sources SET content_visible = ? WHERE id = ?", (1 if body.visible else 0, sid)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
return {"ok": True, "visible": body.visible}
@app.post("/api/admin/sources/{sid}/review")
def admin_source_review(sid: int, body: SourceReviewBody, request: Request) -> dict: