package main import ( "encoding/json" "fmt" "net/http" "time" "github.com/1440news/shared" ) // Database helper methods for publish-related queries // SetPublishStatus updates a feed's publish status and account func (d *Dashboard) SetPublishStatus(feedURL, status, account string) error { _, err := d.db.Exec(` UPDATE feeds SET publish_status = $1, publish_account = $2 WHERE url = $3 `, status, shared.NullableString(account), feedURL) return err } // GetFeedsByPublishStatus returns feeds with a specific publish status func (d *Dashboard) GetFeedsByPublishStatus(status string) ([]*shared.Feed, error) { rows, err := d.db.Query(` SELECT url, type, category, title, description, language, site_url, discovered_at, last_checked_at, next_check_at, last_build_date, status, last_error, last_error_at, source_url, domain_host, domain_tld, item_count, oldest_item_date, newest_item_date, no_update, publish_status, publish_account FROM feeds WHERE publish_status = $1 ORDER BY url ASC `, status) if err != nil { return nil, err } defer rows.Close() var feeds []*shared.Feed for rows.Next() { f := &shared.Feed{} var category, title, description, language, siteUrl *string var lastCheckedAt, nextCheckAt, lastBuildDate, lastErrorAt *time.Time var feedStatus, lastError *string var sourceUrl, domainHost, domainTLD *string var itemCount, noUpdate *int var oldestItemDate, newestItemDate *time.Time var publishStatus, publishAccount *string if err := rows.Scan(&f.URL, &f.Type, &category, &title, &description, &language, &siteUrl, &f.DiscoveredAt, &lastCheckedAt, &nextCheckAt, &lastBuildDate, &feedStatus, &lastError, &lastErrorAt, &sourceUrl, &domainHost, &domainTLD, &itemCount, &oldestItemDate, &newestItemDate, &noUpdate, &publishStatus, &publishAccount); err != nil { continue } f.Category = shared.StringValue(category) f.Title = shared.StringValue(title) f.Description = shared.StringValue(description) f.Language = shared.StringValue(language) f.SiteURL = shared.StringValue(siteUrl) if lastCheckedAt != nil { f.LastCheckedAt = *lastCheckedAt } if nextCheckAt != nil { f.NextCheckAt = *nextCheckAt } if lastBuildDate != nil { f.LastBuildDate = *lastBuildDate } f.Status = shared.StringValue(feedStatus) f.LastError = shared.StringValue(lastError) if lastErrorAt != nil { f.LastErrorAt = *lastErrorAt } f.SourceURL = shared.StringValue(sourceUrl) f.DomainHost = shared.StringValue(domainHost) f.DomainTLD = shared.StringValue(domainTLD) if itemCount != nil { f.ItemCount = *itemCount } if oldestItemDate != nil { f.OldestItemDate = *oldestItemDate } if newestItemDate != nil { f.NewestItemDate = *newestItemDate } if noUpdate != nil { f.NoUpdate = *noUpdate } f.PublishStatus = shared.StringValue(publishStatus) f.PublishAccount = shared.StringValue(publishAccount) feeds = append(feeds, f) } return feeds, nil } // GetUnpublishedItemCount returns count of unpublished items for a feed func (d *Dashboard) GetUnpublishedItemCount(feedURL string) (int, error) { var count int err := d.db.QueryRow(` SELECT COUNT(*) FROM items WHERE feed_url = $1 AND published_at IS NULL `, feedURL).Scan(&count) return count, err } // GetPublishCandidates returns feeds pending review that have items func (d *Dashboard) GetPublishCandidates(limit int) ([]*shared.Feed, error) { rows, err := d.db.Query(` SELECT url, type, category, title, description, domain_host, domain_tld, item_count FROM feeds WHERE publish_status = 'hold' AND status = 'pass' AND item_count > 0 AND language = 'en' ORDER BY item_count DESC LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() var feeds []*shared.Feed for rows.Next() { f := &shared.Feed{} var category, title, description, domainHost, domainTLD *string var itemCount *int if err := rows.Scan(&f.URL, &f.Type, &category, &title, &description, &domainHost, &domainTLD, &itemCount); err != nil { continue } f.Category = shared.StringValue(category) if f.Category == "" { f.Category = "main" } f.Title = shared.StringValue(title) f.Description = shared.StringValue(description) f.DomainHost = shared.StringValue(domainHost) f.DomainTLD = shared.StringValue(domainTLD) if itemCount != nil { f.ItemCount = *itemCount } feeds = append(feeds, f) } return feeds, nil } // GetUnpublishedItems returns unpublished items for a feed func (d *Dashboard) GetUnpublishedItems(feedURL string, limit int) ([]*shared.Item, error) { rows, err := d.db.Query(` SELECT feed_url, guid, title, link, description, content, author, pub_date, discovered_at, updated_at FROM items WHERE feed_url = $1 AND published_at IS NULL ORDER BY pub_date ASC LIMIT $2 `, feedURL, limit) if err != nil { return nil, err } defer rows.Close() var items []*shared.Item for rows.Next() { var item shared.Item var guid, title, link, description, content, author *string var pubDate, discoveredAt, updatedAt *time.Time if err := rows.Scan(&item.FeedURL, &guid, &title, &link, &description, &content, &author, &pubDate, &discoveredAt, &updatedAt); err != nil { continue } item.GUID = shared.StringValue(guid) item.Title = shared.StringValue(title) item.Link = shared.StringValue(link) item.Description = shared.StringValue(description) item.Content = shared.StringValue(content) item.Author = shared.StringValue(author) if pubDate != nil { item.PubDate = *pubDate } if discoveredAt != nil { item.DiscoveredAt = *discoveredAt } if updatedAt != nil { item.UpdatedAt = *updatedAt } items = append(items, &item) } return items, nil } // getFeed retrieves a single feed by URL func (d *Dashboard) getFeed(feedURL string) (*shared.Feed, error) { f := &shared.Feed{} var category, title, description, language, siteUrl *string var lastCheckedAt, nextCheckAt, lastBuildDate, lastErrorAt *time.Time var status, lastError *string var sourceUrl, domainHost, domainTLD *string var itemCount, noUpdate *int var oldestItemDate, newestItemDate *time.Time var publishStatus, publishAccount *string var etag, lastModified *string err := d.db.QueryRow(` SELECT url, type, category, title, description, language, site_url, discovered_at, last_checked_at, next_check_at, last_build_date, etag, last_modified, status, last_error, last_error_at, source_url, domain_host, domain_tld, item_count, oldest_item_date, newest_item_date, no_update, publish_status, publish_account FROM feeds WHERE url = $1 `, feedURL).Scan(&f.URL, &f.Type, &category, &title, &description, &language, &siteUrl, &f.DiscoveredAt, &lastCheckedAt, &nextCheckAt, &lastBuildDate, &etag, &lastModified, &status, &lastError, &lastErrorAt, &sourceUrl, &domainHost, &domainTLD, &itemCount, &oldestItemDate, &newestItemDate, &noUpdate, &publishStatus, &publishAccount) if err != nil { return nil, err } f.Category = shared.StringValue(category) f.Title = shared.StringValue(title) f.Description = shared.StringValue(description) f.Language = shared.StringValue(language) f.SiteURL = shared.StringValue(siteUrl) if lastCheckedAt != nil { f.LastCheckedAt = *lastCheckedAt } if nextCheckAt != nil { f.NextCheckAt = *nextCheckAt } if lastBuildDate != nil { f.LastBuildDate = *lastBuildDate } f.ETag = shared.StringValue(etag) f.LastModified = shared.StringValue(lastModified) f.Status = shared.StringValue(status) f.LastError = shared.StringValue(lastError) if lastErrorAt != nil { f.LastErrorAt = *lastErrorAt } f.SourceURL = shared.StringValue(sourceUrl) f.DomainHost = shared.StringValue(domainHost) f.DomainTLD = shared.StringValue(domainTLD) if itemCount != nil { f.ItemCount = *itemCount } if oldestItemDate != nil { f.OldestItemDate = *oldestItemDate } if newestItemDate != nil { f.NewestItemDate = *newestItemDate } if noUpdate != nil { f.NoUpdate = *noUpdate } f.PublishStatus = shared.StringValue(publishStatus) f.PublishAccount = shared.StringValue(publishAccount) return f, nil } // API Handlers // handleAPIEnablePublish sets a feed's publish status to 'pass' (database only, no PDS account creation) func (d *Dashboard) handleAPIEnablePublish(w http.ResponseWriter, r *http.Request) { feedURL := r.URL.Query().Get("url") account := r.URL.Query().Get("account") if feedURL == "" { http.Error(w, "url parameter required", http.StatusBadRequest) return } feedURL = shared.NormalizeURL(feedURL) // Auto-derive account handle if not provided if account == "" { account = shared.DeriveHandleFromFeed(feedURL) if account == "" { http.Error(w, "could not derive account handle from URL", http.StatusBadRequest) return } } // Check feed exists feed, err := d.getFeed(feedURL) if err != nil { http.Error(w, "feed not found", http.StatusNotFound) return } if feed == nil { http.Error(w, "feed not found", http.StatusNotFound) return } if err := d.SetPublishStatus(feedURL, "pass", account); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Get unpublished count count, _ := d.GetUnpublishedItemCount(feedURL) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "status": "pass", "url": feedURL, "account": account, "unpublished_items": count, "note": "PDS account must be created via publisher service", }) } // handleAPIDeriveHandle shows what handle would be derived from a feed URL func (d *Dashboard) handleAPIDeriveHandle(w http.ResponseWriter, r *http.Request) { feedURL := r.URL.Query().Get("url") if feedURL == "" { http.Error(w, "url parameter required", http.StatusBadRequest) return } handle := shared.DeriveHandleFromFeed(feedURL) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "url": feedURL, "handle": handle, }) } // handleAPIDisablePublish sets a feed's publish status to 'skip' func (d *Dashboard) handleAPIDisablePublish(w http.ResponseWriter, r *http.Request) { feedURL := r.URL.Query().Get("url") if feedURL == "" { http.Error(w, "url parameter required", http.StatusBadRequest) return } feedURL = shared.NormalizeURL(feedURL) if err := d.SetPublishStatus(feedURL, "skip", ""); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "status": "skip", "url": feedURL, }) } // handleAPIPublishEnabled returns all feeds with publish status 'pass' func (d *Dashboard) handleAPIPublishEnabled(w http.ResponseWriter, r *http.Request) { feeds, err := d.GetFeedsByPublishStatus("pass") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type FeedPublishInfo struct { URL string `json:"url"` Title string `json:"title"` Account string `json:"account"` UnpublishedCount int `json:"unpublished_count"` } var result []FeedPublishInfo for _, f := range feeds { count, _ := d.GetUnpublishedItemCount(f.URL) result = append(result, FeedPublishInfo{ URL: f.URL, Title: f.Title, Account: f.PublishAccount, UnpublishedCount: count, }) } if result == nil { result = []FeedPublishInfo{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // handleAPIPublishDenied returns all feeds with publish status 'skip' func (d *Dashboard) handleAPIPublishDenied(w http.ResponseWriter, r *http.Request) { feeds, err := d.GetFeedsByPublishStatus("skip") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type FeedDeniedInfo struct { URL string `json:"url"` Title string `json:"title"` SourceHost string `json:"source_host"` } var result []FeedDeniedInfo for _, f := range feeds { result = append(result, FeedDeniedInfo{ URL: f.URL, Title: f.Title, SourceHost: f.DomainHost, }) } if result == nil { result = []FeedDeniedInfo{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // handleAPIPublishCandidates returns feeds pending review that have items func (d *Dashboard) handleAPIPublishCandidates(w http.ResponseWriter, r *http.Request) { limit := 50 if l := r.URL.Query().Get("limit"); l != "" { fmt.Sscanf(l, "%d", &limit) if limit > 200 { limit = 200 } } feeds, err := d.GetPublishCandidates(limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type CandidateInfo struct { URL string `json:"url"` Title string `json:"title"` Category string `json:"category"` SourceHost string `json:"source_host"` ItemCount int `json:"item_count"` DerivedHandle string `json:"derived_handle"` } var result []CandidateInfo for _, f := range feeds { result = append(result, CandidateInfo{ URL: f.URL, Title: f.Title, Category: f.Category, SourceHost: f.DomainHost, ItemCount: f.ItemCount, DerivedHandle: shared.DeriveHandleFromFeed(f.URL), }) } if result == nil { result = []CandidateInfo{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // handleAPISetPublishStatus sets the publish status for a feed (database only) func (d *Dashboard) handleAPISetPublishStatus(w http.ResponseWriter, r *http.Request) { feedURL := r.URL.Query().Get("url") status := r.URL.Query().Get("status") account := r.URL.Query().Get("account") if feedURL == "" { http.Error(w, "url parameter required", http.StatusBadRequest) return } if status != "pass" && status != "skip" && status != "hold" { http.Error(w, "status must be 'pass', 'hold', or 'skip' (use publisher service for 'drop')", http.StatusBadRequest) return } feedURL = shared.NormalizeURL(feedURL) result := map[string]interface{}{ "url": feedURL, "status": status, } // Handle 'pass' - set account if status == "pass" { if account == "" { account = shared.DeriveHandleFromFeed(feedURL) } result["account"] = account result["note"] = "PDS account must be created via publisher service" } // Handle 'hold' and 'skip' - preserve current account if status == "hold" || status == "skip" { feed, _ := d.getFeed(feedURL) if feed != nil { account = feed.PublishAccount } } if err := d.SetPublishStatus(feedURL, status, account); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } result["account"] = account w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // handleAPIUnpublishedItems returns unpublished items for a feed func (d *Dashboard) handleAPIUnpublishedItems(w http.ResponseWriter, r *http.Request) { feedURL := r.URL.Query().Get("url") if feedURL == "" { http.Error(w, "url parameter required", http.StatusBadRequest) return } limit := 50 if l := r.URL.Query().Get("limit"); l != "" { fmt.Sscanf(l, "%d", &limit) if limit > 200 { limit = 200 } } items, err := d.GetUnpublishedItems(feedURL, limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if items == nil { items = []*shared.Item{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(items) } // The following handlers require PDS interaction and should use the publisher service func (d *Dashboard) handleAPITestPublish(w http.ResponseWriter, r *http.Request) { http.Error(w, "Publishing requires the publisher service. This endpoint is not available in standalone dashboard.", http.StatusNotImplemented) } func (d *Dashboard) handleAPIPublishFeed(w http.ResponseWriter, r *http.Request) { http.Error(w, "Publishing requires the publisher service. This endpoint is not available in standalone dashboard.", http.StatusNotImplemented) } func (d *Dashboard) handleAPICreateAccount(w http.ResponseWriter, r *http.Request) { http.Error(w, "Account creation requires the publisher service. This endpoint is not available in standalone dashboard.", http.StatusNotImplemented) } func (d *Dashboard) handleAPIPublishFeedFull(w http.ResponseWriter, r *http.Request) { http.Error(w, "Publishing requires the publisher service. This endpoint is not available in standalone dashboard.", http.StatusNotImplemented) } func (d *Dashboard) handleAPIUpdateProfile(w http.ResponseWriter, r *http.Request) { http.Error(w, "Profile updates require the publisher service. This endpoint is not available in standalone dashboard.", http.StatusNotImplemented) } func (d *Dashboard) handleAPIResetAllPublishing(w http.ResponseWriter, r *http.Request) { http.Error(w, "This destructive operation requires the publisher service. This endpoint is not available in standalone dashboard.", http.StatusNotImplemented) } func (d *Dashboard) handleAPIRefreshProfiles(w http.ResponseWriter, r *http.Request) { http.Error(w, "Profile refresh requires the publisher service. This endpoint is not available in standalone dashboard.", http.StatusNotImplemented) }