From 2aa4eb77cf83733854fde021d6717bdce2c8480c Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Mon, 9 Feb 2026 08:51:53 -0600 Subject: [PATCH] feat: bulk post controls - post N or all pending articles from admin UI --- admin/src/app/official-accounts/page.tsx | 42 ++++++++++++ admin/src/lib/api.ts | 5 +- go-backend/internal/handlers/admin_handler.go | 64 ++++++++++++++----- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/admin/src/app/official-accounts/page.tsx b/admin/src/app/official-accounts/page.tsx index 085e8ec..ea4bcb0 100644 --- a/admin/src/app/official-accounts/page.tsx +++ b/admin/src/app/official-accounts/page.tsx @@ -657,6 +657,9 @@ function ArticlesPanel({ configId }: { configId: string }) { const [stats, setStats] = useState(null); const [tab, setTab] = useState('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 }) { ))} + {/* Bulk post controls — only on discovered tab */} + {tab === 'discovered' && stats && stats.discovered > 0 && ( +
+ Post: + 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" /> + + + {postResult && ( + {postResult.msg} + )} +
+ )} + {/* Article list */}
{loading ? ( diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index d2a304a..cb37806 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -425,8 +425,9 @@ class ApiClient { }); } - async triggerOfficialPost(id: string) { - return this.request(`/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(`/api/v1/admin/official-accounts/${id}/trigger${q}`, { method: 'POST' }); } async previewOfficialPost(id: string) { diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 70b3f0c..4ceb217 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -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: