diff --git a/go-backend/internal/services/official_accounts_service.go b/go-backend/internal/services/official_accounts_service.go index ba246ac..71f6543 100644 --- a/go-backend/internal/services/official_accounts_service.go +++ b/go-backend/internal/services/official_accounts_service.go @@ -514,7 +514,29 @@ func (s *OfficialAccountsService) PostNextArticle(ctx context.Context, configID return &art, postID, nil } +// ReconcilePostedArticles checks for articles marked 'posted' whose post no longer +// exists in the posts table, and reverts them to 'discovered' so they can be re-posted. +func (s *OfficialAccountsService) ReconcilePostedArticles(ctx context.Context, configID string) (int, error) { + tag, err := s.pool.Exec(ctx, ` + UPDATE official_account_articles a + SET status = 'discovered', post_id = NULL, posted_at = NULL + WHERE a.config_id = $1 + AND a.status = 'posted' + AND a.post_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM public.posts p WHERE p.id::text = a.post_id) + `, configID) + if err != nil { + return 0, err + } + reverted := int(tag.RowsAffected()) + if reverted > 0 { + log.Info().Int("reverted", reverted).Str("config", configID).Msg("[OfficialAccounts] Reconciled orphaned articles back to discovered") + } + return reverted, nil +} + // GetArticleQueue returns articles for a config filtered by status. +// For 'posted' status, only returns articles whose post still exists in the posts table. func (s *OfficialAccountsService) GetArticleQueue(ctx context.Context, configID string, status string, limit int) ([]CachedArticle, error) { if limit <= 0 { limit = 50 @@ -524,12 +546,17 @@ func (s *OfficialAccountsService) GetArticleQueue(ctx context.Context, configID orderDir = "ASC" // oldest first (next to be posted) } + // For 'posted', reconcile first to catch deleted posts, then query + if status == "posted" { + _, _ = s.ReconcilePostedArticles(ctx, configID) + } + query := fmt.Sprintf(` - SELECT id, config_id, guid, title, link, source_name, source_url, description, pub_date, - status, post_id, error_message, discovered_at, posted_at - FROM official_account_articles - WHERE config_id = $1 AND status = $2 - ORDER BY discovered_at %s + SELECT a.id, a.config_id, a.guid, a.title, a.link, a.source_name, a.source_url, a.description, a.pub_date, + a.status, a.post_id, a.error_message, a.discovered_at, a.posted_at + FROM official_account_articles a + WHERE a.config_id = $1 AND a.status = $2 + ORDER BY a.discovered_at %s LIMIT $3 `, orderDir) @@ -564,7 +591,11 @@ type ArticleStats struct { } // GetArticleStats returns article counts by status for a config. +// Reconciles orphaned articles first so counts reflect reality. func (s *OfficialAccountsService) GetArticleStats(ctx context.Context, configID string) (*ArticleStats, error) { + // Reconcile first — revert articles whose posts were deleted + _, _ = s.ReconcilePostedArticles(ctx, configID) + rows, err := s.pool.Query(ctx, ` SELECT status, COUNT(*) FROM official_account_articles WHERE config_id = $1