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
This commit is contained in:
Alan Weinstock 2026-04-21 16:51:10 -07:00
parent bd58f9f54e
commit fb036ae064
4 changed files with 697 additions and 0 deletions

1
.gitignore vendored
View file

@ -21,6 +21,7 @@ debug_*.png
*.db
*.db-wal
*.db-shm
data/
# IDE
.vscode/

View file

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

View file

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

View file

@ -0,0 +1,523 @@
<template>
<div class="opportunities-view">
<div class="view-header">
<h1 class="view-title">Opportunities</h1>
<div class="header-actions">
<select v-model="filterStatus" class="status-filter">
<option value="">All</option>
<option value="pending_review">Pending review</option>
<option value="approved">Approved</option>
<option value="posted">Posted</option>
<option value="manual_posted">Manual posted</option>
<option value="dismissed">Dismissed</option>
</select>
<button class="btn btn-primary" @click="showAddModal = true">+ Add</button>
</div>
</div>
<div v-if="loading" class="state-empty">Loading...</div>
<div v-else-if="filtered.length === 0" class="state-empty">
No opportunities{{ filterStatus ? ` with status "${filterStatus}"` : '' }}.
</div>
<div v-else class="opp-list">
<div
v-for="opp in filtered"
:key="opp.id"
class="opp-card"
:class="`status-${opp.status}`"
@click="select(opp)"
>
<div class="opp-meta">
<span class="opp-platform">{{ opp.platform }}</span>
<span class="opp-community">{{ opp.community }}</span>
<span class="opp-type">{{ opp.post_type === 'reply_to_thread' ? 'reply' : 'new post' }}</span>
<span v-if="opp.product" class="opp-product">{{ opp.product }}</span>
</div>
<div class="opp-title">{{ opp.thread_title || opp.thread_url }}</div>
<div v-if="opp.signal_reason" class="opp-signal">{{ opp.signal_reason }}</div>
<div class="opp-footer">
<span class="status-badge" :class="`badge-${opp.status}`">{{ opp.status.replace('_', ' ') }}</span>
<span class="opp-date">{{ formatDate(opp.created_at) }}</span>
</div>
</div>
</div>
<!-- Detail / review panel -->
<div v-if="selected" class="detail-overlay" @click.self="selected = null">
<div class="detail-panel">
<button class="close-btn" @click="selected = null"></button>
<div class="detail-header">
<div class="opp-meta">
<span class="opp-platform">{{ selected.platform }}</span>
<span class="opp-community">{{ selected.community }}</span>
<span class="opp-type">{{ selected.post_type === 'reply_to_thread' ? 'reply' : 'new post' }}</span>
</div>
<span class="status-badge" :class="`badge-${selected.status}`">{{ selected.status.replace('_', ' ') }}</span>
</div>
<!-- Thread context -->
<section class="detail-section">
<h3 class="section-label">Thread</h3>
<a :href="selected.thread_url" target="_blank" class="thread-link">
{{ selected.thread_title || selected.thread_url }}
</a>
<p v-if="selected.thread_body" class="thread-body">{{ selected.thread_body }}</p>
</section>
<section v-if="selected.signal_reason" class="detail-section">
<h3 class="section-label">Why flagged</h3>
<p class="signal-reason">{{ selected.signal_reason }}</p>
</section>
<!-- Draft editor -->
<section class="detail-section">
<h3 class="section-label">Draft {{ selected.post_type === 'new_post' ? 'post' : 'reply' }}</h3>
<input
v-if="selected.post_type === 'new_post'"
v-model="editTitle"
class="input draft-title-input"
placeholder="Post title"
/>
<textarea
v-model="editBody"
class="textarea draft-body"
rows="10"
placeholder="Draft content..."
/>
<button
v-if="editBody !== selected.draft_body || editTitle !== (selected.draft_title ?? '')"
class="btn btn-secondary"
:disabled="saving"
@click="saveDraft"
>
Save draft
</button>
</section>
<!-- Actions -->
<section v-if="selected.status === 'pending_review'" class="detail-actions">
<button class="btn btn-primary" :disabled="saving" @click="approve">
Approve
</button>
<button class="btn btn-danger" :disabled="saving" @click="dismiss">
Dismiss
</button>
</section>
<!-- Handoff panel for approved non-Reddit -->
<section v-if="selected.status === 'approved' && selected.platform !== 'reddit'" class="handoff-panel">
<h3 class="section-label">Manual handoff</h3>
<div class="handoff-actions">
<button class="btn btn-secondary" @click="copyDraft">📋 Copy draft</button>
<a :href="selected.thread_url" target="_blank" class="btn btn-secondary">🔗 Open thread</a>
<button class="btn btn-success" :disabled="saving" @click="markManualPosted">
Mark as posted
</button>
</div>
<div v-if="copied" class="copy-confirm">Copied to clipboard</div>
</section>
<!-- Auto-post panel for approved Reddit -->
<section v-if="selected.status === 'approved' && selected.platform === 'reddit'" class="handoff-panel">
<h3 class="section-label">Ready to post</h3>
<p class="handoff-note">Use trigger_sub_post from the Campaigns view, or mark as posted manually if you handled it.</p>
<button class="btn btn-success" :disabled="saving" @click="markManualPosted">
Mark as posted
</button>
</section>
</div>
</div>
<!-- Add opportunity modal -->
<div v-if="showAddModal" class="detail-overlay" @click.self="showAddModal = false">
<div class="detail-panel">
<button class="close-btn" @click="showAddModal = false"></button>
<h2 class="panel-title">Add opportunity</h2>
<div class="form-grid">
<label class="form-label">Thread URL
<input v-model="newOpp.thread_url" class="input" placeholder="https://..." />
</label>
<label class="form-label">Platform
<select v-model="newOpp.platform" class="input">
<option>reddit</option>
<option>lemmy</option>
<option>linkedin</option>
<option>other</option>
</select>
</label>
<label class="form-label">Community
<input v-model="newOpp.community" class="input" placeholder="selfhosted, lemmy.world/c/lsc, etc." />
</label>
<label class="form-label">Post type
<select v-model="newOpp.post_type" class="input">
<option value="reply_to_thread">Reply to thread</option>
<option value="new_post">New post</option>
</select>
</label>
<label class="form-label">Product
<select v-model="newOpp.product" class="input">
<option value=""> any </option>
<option>peregrine</option>
<option>kiwi</option>
<option>snipe</option>
<option>circuitforge</option>
</select>
</label>
<label class="form-label">Thread title (optional)
<input v-model="newOpp.thread_title" class="input" placeholder="Original post title" />
</label>
<label class="form-label" style="grid-column: 1 / -1">Why flagged
<input v-model="newOpp.signal_reason" class="input" placeholder="Pain point match, keyword, etc." />
</label>
<label class="form-label" style="grid-column: 1 / -1">Draft
<textarea v-model="newOpp.draft_body" class="textarea" rows="6" placeholder="Draft reply or post body..." />
</label>
</div>
<div class="detail-actions">
<button class="btn btn-primary" :disabled="saving || !newOpp.thread_url || !newOpp.community" @click="createOpp">
Add to queue
</button>
<button class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { api, type Opportunity, type OpportunityStatus } from '../services/api'
const opportunities = ref<Opportunity[]>([])
const loading = ref(false)
const saving = ref(false)
const selected = ref<Opportunity | null>(null)
const showAddModal = ref(false)
const copied = ref(false)
const filterStatus = ref<OpportunityStatus | ''>('')
const editBody = ref('')
const editTitle = ref('')
const newOpp = ref({
platform: 'reddit',
community: '',
thread_url: '',
thread_title: '',
signal_reason: '',
product: '',
draft_body: '',
post_type: 'reply_to_thread' as const,
})
const filtered = computed(() =>
filterStatus.value
? opportunities.value.filter(o => o.status === filterStatus.value)
: opportunities.value
)
async function load() {
loading.value = true
try {
opportunities.value = await api.opportunities.list()
} finally {
loading.value = false
}
}
function select(opp: Opportunity) {
selected.value = opp
editBody.value = opp.draft_body
editTitle.value = opp.draft_title ?? ''
}
watch(selected, opp => {
if (opp) {
editBody.value = opp.draft_body
editTitle.value = opp.draft_title ?? ''
}
})
async function saveDraft() {
if (!selected.value) return
saving.value = true
try {
const updated = await api.opportunities.update(selected.value.id, {
draft_body: editBody.value,
draft_title: editTitle.value || undefined,
})
replace(updated)
} finally {
saving.value = false
}
}
async function approve() {
if (!selected.value) return
saving.value = true
try {
// Save any unsaved draft edits first
if (editBody.value !== selected.value.draft_body) {
await api.opportunities.update(selected.value.id, { draft_body: editBody.value })
}
const result = await api.opportunities.approve(selected.value.id)
replace(result.opportunity)
} finally {
saving.value = false
}
}
async function dismiss() {
if (!selected.value) return
saving.value = true
try {
const updated = await api.opportunities.dismiss(selected.value.id)
replace(updated)
} finally {
saving.value = false
}
}
async function markManualPosted() {
if (!selected.value) return
saving.value = true
try {
const updated = await api.opportunities.markPosted(selected.value.id, true)
replace(updated)
} finally {
saving.value = false
}
}
async function copyDraft() {
if (!selected.value) return
await navigator.clipboard.writeText(selected.value.draft_body)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
async function createOpp() {
saving.value = true
try {
const created = await api.opportunities.create({
...newOpp.value,
product: newOpp.value.product || undefined,
thread_title: newOpp.value.thread_title || undefined,
signal_reason: newOpp.value.signal_reason || undefined,
})
opportunities.value.unshift(created)
showAddModal.value = false
newOpp.value = { platform: 'reddit', community: '', thread_url: '', thread_title: '', signal_reason: '', product: '', draft_body: '', post_type: 'reply_to_thread' }
} finally {
saving.value = false
}
}
function replace(updated: Opportunity) {
const idx = opportunities.value.findIndex(o => o.id === updated.id)
if (idx >= 0) opportunities.value[idx] = updated
if (selected.value?.id === updated.id) selected.value = updated
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
onMounted(load)
</script>
<style scoped>
.opportunities-view { padding: var(--spacing-lg); }
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.view-title { margin: 0; font-size: 1.4rem; color: var(--color-text); }
.header-actions { display: flex; gap: var(--spacing-sm); align-items: center; }
.status-filter {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
font-size: 0.85rem;
}
.state-empty { color: var(--color-text-muted); text-align: center; padding: var(--spacing-xl); }
.opp-list { display: flex; flex-direction: column; gap: var(--spacing-sm); }
.opp-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
cursor: pointer;
transition: border-color 0.15s;
}
.opp-card:hover { border-color: var(--color-primary); }
.opp-card.status-dismissed { opacity: 0.5; }
.opp-meta { display: flex; gap: var(--spacing-sm); align-items: center; margin-bottom: var(--spacing-xs); flex-wrap: wrap; }
.opp-platform, .opp-community, .opp-type, .opp-product {
font-size: 0.75rem;
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.opp-platform { color: var(--color-info); border-color: var(--color-info); }
.opp-community { color: var(--color-primary); border-color: var(--color-primary); }
.opp-title { font-size: 0.95rem; color: var(--color-text); margin-bottom: var(--spacing-xs); }
.opp-signal { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: var(--spacing-xs); font-style: italic; }
.opp-footer { display: flex; align-items: center; justify-content: space-between; }
.opp-date { font-size: 0.75rem; color: var(--color-text-muted); }
.status-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 999px;
text-transform: capitalize;
font-weight: 600;
}
.badge-pending_review { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
.badge-approved { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.badge-posted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.badge-manual_posted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.badge-dismissed { background: color-mix(in srgb, var(--color-danger) 15%, transparent); color: var(--color-danger); }
/* Detail panel */
.detail-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; align-items: flex-start; justify-content: flex-end;
z-index: 100;
padding: var(--spacing-md);
}
.detail-panel {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: min(680px, 95vw);
max-height: calc(100vh - 2 * var(--spacing-md));
overflow-y: auto;
padding: var(--spacing-lg);
position: relative;
}
.close-btn {
position: absolute; top: var(--spacing-md); right: var(--spacing-md);
background: none; border: none; color: var(--color-text-muted);
font-size: 1.1rem; cursor: pointer; padding: 4px;
}
.close-btn:hover { color: var(--color-text); }
.panel-title { margin: 0 0 var(--spacing-lg); font-size: 1.1rem; }
.detail-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: var(--spacing-md);
}
.detail-section { margin-bottom: var(--spacing-lg); }
.section-label {
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--color-text-muted); margin: 0 0 var(--spacing-sm);
}
.thread-link {
color: var(--color-info); text-decoration: none; font-size: 0.95rem;
display: block; margin-bottom: var(--spacing-sm);
}
.thread-link:hover { text-decoration: underline; }
.thread-body {
font-size: 0.85rem; color: var(--color-text-muted);
background: var(--color-bg); border-radius: var(--radius-sm);
padding: var(--spacing-sm); margin: 0;
white-space: pre-wrap; max-height: 120px; overflow-y: auto;
}
.signal-reason { font-size: 0.85rem; color: var(--color-text-muted); margin: 0; font-style: italic; }
.draft-title-input { width: 100%; margin-bottom: var(--spacing-sm); }
.draft-body { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.85rem; }
.detail-actions { display: flex; gap: var(--spacing-sm); flex-wrap: wrap; margin-top: var(--spacing-md); }
.handoff-panel {
background: color-mix(in srgb, var(--color-success) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent);
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-top: var(--spacing-md);
}
.handoff-actions { display: flex; gap: var(--spacing-sm); flex-wrap: wrap; margin-top: var(--spacing-sm); }
.handoff-note { font-size: 0.85rem; color: var(--color-text-muted); margin: var(--spacing-xs) 0 0; }
.copy-confirm {
font-size: 0.8rem; color: var(--color-success);
margin-top: var(--spacing-xs);
}
/* Add form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); }
.form-label {
display: flex; flex-direction: column; gap: var(--spacing-xs);
font-size: 0.8rem; color: var(--color-text-muted);
}
/* Shared */
.input, .textarea {
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text);
border-radius: var(--radius-sm);
padding: var(--spacing-sm);
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
}
.input:focus, .textarea:focus { outline: none; border-color: var(--color-primary); }
.btn {
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 0.85rem;
cursor: pointer;
transition: opacity 0.15s;
text-decoration: none;
display: inline-block;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--color-primary); color: #fff; }
.btn-secondary { background: var(--color-bg-card); color: var(--color-text); border-color: var(--color-border); }
.btn-danger { background: color-mix(in srgb, var(--color-danger) 15%, transparent); color: var(--color-danger); border-color: var(--color-danger); }
.btn-success { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); border-color: var(--color-success); }
.btn:hover:not(:disabled) { opacity: 0.85; }
@media (max-width: 600px) {
.form-grid { grid-template-columns: 1fr; }
.detail-overlay { padding: 0; align-items: flex-end; }
.detail-panel { width: 100%; border-radius: var(--radius-lg) var(--radius-lg) 0 0; max-height: 90vh; }
}
</style>