feat: bulk post controls - post N or all pending articles from admin UI

This commit is contained in:
Patrick Britton 2026-02-09 08:51:53 -06:00
parent 1064f3e284
commit 2aa4eb77cf
3 changed files with 94 additions and 17 deletions

View file

@ -657,6 +657,9 @@ function ArticlesPanel({ configId }: { configId: string }) {
const [stats, setStats] = useState<ArticleStats | null>(null);
const [tab, setTab] = useState<PipelineTab>('discovered');
const [loading, setLoading] = useState(false);
const [bulkCount, setBulkCount] = useState(5);
const [posting, setPosting] = useState(false);
const [postResult, setPostResult] = useState<{ ok: boolean; msg: string } | null>(null);
const fetchTab = async (t: PipelineTab) => {
setLoading(true);
@ -676,6 +679,24 @@ function ArticlesPanel({ configId }: { configId: string }) {
useEffect(() => { fetchTab(tab); }, [tab]);
const handleBulkPost = async (count: number | 'all') => {
setPosting(true);
setPostResult(null);
try {
const resp = await api.triggerOfficialPost(configId, count);
const n = resp.posted?.length || 0;
const errs = resp.errors?.length || 0;
let msg = `Posted ${n} article(s)`;
if (errs > 0) msg += `, ${errs} error(s)`;
setPostResult({ ok: errs === 0, msg });
if (resp.stats) setStats(resp.stats);
fetchTab(tab);
} catch (e: any) {
setPostResult({ ok: false, msg: e.message });
}
setPosting(false);
};
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
{ key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
@ -714,6 +735,27 @@ function ArticlesPanel({ configId }: { configId: string }) {
))}
</div>
{/* Bulk post controls — only on discovered tab */}
{tab === 'discovered' && stats && stats.discovered > 0 && (
<div className="flex items-center gap-2 px-3 py-2 bg-blue-50 border-b border-warm-200">
<span className="text-[10px] font-medium text-gray-600">Post:</span>
<input type="number" min={1} max={stats.discovered} value={bulkCount}
onChange={(e) => setBulkCount(Math.max(1, Number(e.target.value)))}
className="w-14 px-1.5 py-1 border border-warm-300 rounded text-xs text-center" />
<button onClick={() => handleBulkPost(bulkCount)} disabled={posting}
className="px-2.5 py-1 bg-brand-500 text-white rounded text-xs font-medium hover:bg-brand-600 disabled:opacity-50">
{posting ? 'Posting...' : `Post ${bulkCount}`}
</button>
<button onClick={() => handleBulkPost('all')} disabled={posting}
className="px-2.5 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50">
{posting ? 'Posting...' : `Post All (${stats.discovered})`}
</button>
{postResult && (
<span className={`text-xs ml-auto ${postResult.ok ? 'text-green-600' : 'text-red-600'}`}>{postResult.msg}</span>
)}
</div>
)}
{/* Article list */}
<div className="max-h-56 overflow-y-auto">
{loading ? (

View file

@ -425,8 +425,9 @@ class ApiClient {
});
}
async triggerOfficialPost(id: string) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/trigger`, { method: 'POST' });
async triggerOfficialPost(id: string, count?: number | 'all') {
const q = count !== undefined ? `?count=${count}` : '';
return this.request<any>(`/api/v1/admin/official-accounts/${id}/trigger${q}`, { method: 'POST' });
}
async previewOfficialPost(id: string) {

View file

@ -2970,7 +2970,9 @@ func (h *AdminHandler) ToggleOfficialAccount(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"enabled": req.Enabled})
}
// Manually trigger a post for an official account
// Manually trigger a post for an official account.
// Accepts query param ?count=N to post multiple articles at once.
// count=0 or count=all posts ALL pending articles.
func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
@ -2981,37 +2983,69 @@ func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) {
return
}
// Parse count param: default 1, "all" or "0" = post everything
countStr := c.DefaultQuery("count", "1")
postAll := countStr == "all" || countStr == "0"
count := 1
if !postAll {
if n, err := strconv.Atoi(countStr); err == nil && n > 0 {
count = n
}
}
switch cfg.AccountType {
case "news", "rss":
// Phase 1: Discover new articles
_, discErr := h.officialAccountsService.DiscoverArticles(ctx, id)
discovered, discErr := h.officialAccountsService.DiscoverArticles(ctx, id)
if discErr != nil {
// Log but continue — there may be previously discovered articles
log.Warn().Err(discErr).Str("config", id).Msg("Discover error during trigger")
}
// Phase 2: Post next article from the queue
article, postID, err := h.officialAccountsService.PostNextArticle(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
// If posting all, get the pending count
if postAll {
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
if stats != nil {
count = stats.Discovered
}
}
if article == nil {
// Phase 2: Post N articles from the queue
var posted []gin.H
var errors []string
for i := 0; i < count; i++ {
article, postID, err := h.officialAccountsService.PostNextArticle(ctx, id)
if err != nil {
errors = append(errors, err.Error())
continue
}
if article == nil {
break // no more articles
}
posted = append(posted, gin.H{
"post_id": postID,
"title": article.Title,
"link": article.Link,
"source": article.SourceName,
})
}
if len(posted) == 0 && len(errors) == 0 {
msg := "No new articles found"
if discErr != nil {
msg += " (discover error: " + discErr.Error() + ")"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": nil})
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": nil, "discovered": discovered})
return
}
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
c.JSON(http.StatusOK, gin.H{
"message": "Article posted",
"post_id": postID,
"body": article.Link,
"source": article.SourceName,
"title": article.Title,
"stats": stats,
"message": fmt.Sprintf("Posted %d article(s)", len(posted)),
"posted": posted,
"errors": errors,
"discovered": discovered,
"stats": stats,
})
default: