diff --git a/README.md b/README.md index 0721a4f..1b7e6fc 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ Single-site setup: copy `configs/config.example.yaml` to `configs/config.yaml` a Multi-site setup: copy `configs/multi.example.yaml` to `configs/config.yaml` and customize the `global` block plus each entry in `sites` (including `data_base_path` to keep storage isolated). +For outreach, set `outreach.min_score` and `outreach.min_comments` per site so thresholds match subreddit traffic (for example, `kubernetes` can use higher values than `paternidad`). + ```yaml wordpress: base_url: "https://your-blog.com" diff --git a/configs/multi.example.yaml b/configs/multi.example.yaml index 162ba7c..0ed1de0 100644 --- a/configs/multi.example.yaml +++ b/configs/multi.example.yaml @@ -77,6 +77,9 @@ sites: outreach: subreddit: "kubernetes" + # Higher threshold for high-traffic communities + min_score: 40 + min_comments: 10 newsletter: enabled: true @@ -131,6 +134,9 @@ sites: outreach: subreddit: "paternidad" + # Lower threshold for lower-traffic communities + min_score: 5 + min_comments: 1 newsletter: enabled: false diff --git a/internal/api/server.go b/internal/api/server.go index 4be6a00..b201c12 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -182,6 +182,7 @@ func (s *Server) Start() error { router.HandleFunc("/api/v1/drafts/{id}", s.handleGetDraft).Methods("GET") router.HandleFunc("/api/v1/outreach", s.handleListOutreach).Methods("GET") router.HandleFunc("/api/v1/outreach/generate", s.handleGenerateOutreach).Methods("POST") + router.HandleFunc("/api/v1/outreach/{id}/status", s.handleUpdateOutreachStatus).Methods("POST") router.HandleFunc("/api/v1/outreach/{id}/delete", s.handleDeleteOutreach).Methods("POST") router.HandleFunc("/api/v1/newsletter", s.handleListNewsletter).Methods("GET") router.HandleFunc("/api/v1/newsletter/generate", s.handleGenerateNewsletter).Methods("POST") @@ -855,8 +856,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ - "status": "ok", - "version": s.version, + "status": "ok", + "version": s.version, "base_url": site.WPClient.BaseURL(), }) } @@ -1119,6 +1120,42 @@ func (s *Server) handleDeleteOutreach(w http.ResponseWriter, r *http.Request) { }) } +// handleUpdateOutreachStatus handles POST /api/v1/outreach/{id}/status +func (s *Server) handleUpdateOutreachStatus(w http.ResponseWriter, r *http.Request) { + site, ok := s.requireSite(w, r) + if !ok { + return + } + if site.IdeaSvc == nil { + http.Error(w, "idea service not configured", http.StatusNotImplemented) + return + } + id := mux.Vars(r)["id"] + if id == "" { + http.Error(w, "id is required", http.StatusBadRequest) + return + } + var req struct { + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request payload", http.StatusBadRequest) + return + } + item, err := site.IdeaSvc.UpdateOutreachStatus(id, req.Status) + if err != nil { + http.Error(w, fmt.Sprintf("failed to update outreach suggestion status: %v", err), http.StatusBadRequest) + return + } + s.recordAudit(site, r, &storage.AuditRecord{ + Action: "outreach_status_updated", + Summary: fmt.Sprintf("Updated outreach suggestion %s to %s", id, item.Status), + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(item) +} + // handleListNewsletter handles GET /api/v1/newsletter?status=draft func (s *Server) handleListNewsletter(w http.ResponseWriter, r *http.Request) { site, ok := s.requireSite(w, r) @@ -1762,6 +1799,14 @@ func (s *Server) handleListOrphans(w http.ResponseWriter, r *http.Request) { http.Error(w, "translation service not configured", http.StatusNotImplemented) return } + if len(site.Config.Scheduler.Languages) < 2 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "count": 0, + "orphans": []interface{}{}, + }) + return + } source := r.URL.Query().Get("source") target := r.URL.Query().Get("target") orphans, err := site.TranslationSvc.ListOrphanPosts(r.Context(), source, target) diff --git a/internal/api/ui/app.js b/internal/api/ui/app.js index 7a2ed0a..85401bd 100644 --- a/internal/api/ui/app.js +++ b/internal/api/ui/app.js @@ -128,6 +128,7 @@ const state = { imageAudit: null, seoAudit: null, orphans: [], + outreachStatus: localStorage.getItem("wpsk-outreach-status") || "new", pagination: {}, seoRecommendations: {}, currentPage: "overview", @@ -159,6 +160,11 @@ const toggleHidden = (id, hidden) => { el.classList.toggle("hidden", hidden); }; +const siteSupportsOrphans = (site) => { + const langs = site?.languages || []; + return Array.isArray(langs) && langs.length > 1; +}; + const applySiteFeatures = (site) => { if (!site || !site.features) return; toggleHidden("panel-newsletter", !site.features.newsletter); @@ -167,6 +173,7 @@ const applySiteFeatures = (site) => { toggleHidden("generate-outreach", !site.features.outreach); toggleHidden("panel-ideas", !site.features.ideas); toggleHidden("generate-ideas", !site.features.ideas); + toggleHidden("panel-orphans", !siteSupportsOrphans(site)); }; const setCurrentSite = async (siteId, { refresh = true } = {}) => { @@ -919,6 +926,22 @@ const renderIdeas = () => { }); }; +const getOutreachFilter = () => { + const select = byId("outreach-status-filter"); + if (select && select.value) return select.value; + return state.outreachStatus || "new"; +}; + +const setOutreachFilter = (status) => { + const next = status || "new"; + state.outreachStatus = next; + localStorage.setItem("wpsk-outreach-status", next); + const select = byId("outreach-status-filter"); + if (select) { + select.value = next; + } +}; + const renderOutreach = () => { const list = byId("outreach-list"); renderPaginatedList({ @@ -931,12 +954,26 @@ const renderOutreach = () => { renderItem: (item) => { const el = document.createElement("div"); el.className = "item"; + const status = item.status || "new"; + const actions = [ + ``, + ]; + if (status !== "drafted") { + actions.push(``); + } + if (status !== "reviewed") { + actions.push(``); + } + if (status !== "new") { + actions.push(``); + } + actions.push(``); el.innerHTML = `

${item.reddit_title || "Reddit Thread"}

${item.article_title || "Article"}
+
${status}
- - + ${actions.join("")}
`; list.appendChild(el); @@ -1376,12 +1413,15 @@ const loadIdeas = async () => { const loadOutreach = async () => { setLoading("panel-outreach", true); try { - const res = await apiFetch("/api/v1/outreach?status=new"); + const status = getOutreachFilter(); + const res = await apiFetch(`/api/v1/outreach?status=${encodeURIComponent(status)}`); state.outreach = res.items || []; renderOutreach(); + + const newRes = await apiFetch("/api/v1/outreach?status=new"); const alert = byId("outreach-alert"); - if (res.count > 0) { - alert.textContent = `${res.count} pending outreach suggestions`; + if (newRes.count > 0) { + alert.textContent = `${newRes.count} pending outreach suggestions`; alert.classList.remove("hidden"); } else { alert.classList.add("hidden"); @@ -1436,6 +1476,12 @@ const loadSEOAudit = async () => { }; const loadOrphans = async () => { + const site = state.currentSite ? state.siteMap[state.currentSite] : null; + if (!siteSupportsOrphans(site)) { + state.orphans = []; + renderOrphans(); + return; + } setLoading("panel-orphans", true); try { const res = await apiFetch("/api/v1/orphans?source=en&target=es"); @@ -1466,9 +1512,14 @@ const refreshAll = async () => { loadBrokenLinks(), loadImageAudit(), loadSEOAudit(), - loadOrphans(), loadAudit(), ]; + if (siteSupportsOrphans(site)) { + tasks.push(loadOrphans()); + } else { + state.orphans = []; + renderOrphans(); + } if (features.ideas !== false) { tasks.push(loadIdeas()); } @@ -1605,7 +1656,13 @@ const commandHandlers = { }; return JSON.stringify(await apiFetch("/api/v1/search", { method: "POST", body: JSON.stringify(payload) }), null, 2); }, - orphans: async () => JSON.stringify(await apiFetch("/api/v1/orphans?source=en&target=es"), null, 2), + orphans: async () => { + const site = state.currentSite ? state.siteMap[state.currentSite] : null; + if (!siteSupportsOrphans(site)) { + return "Orphan posts are only available for sites with multiple languages."; + } + return JSON.stringify(await apiFetch("/api/v1/orphans?source=en&target=es"), null, 2); + }, translate: async (args) => { if (!args[0]) return "translate [lang]"; return JSON.stringify( @@ -1781,7 +1838,8 @@ const runCommand = async () => { try { const res = await handler(args); setOutput(res); - await Promise.all([ + const site = state.currentSite ? state.siteMap[state.currentSite] : null; + const reloadTasks = [ loadPending(), loadIdeas(), loadOutreach(), @@ -1789,8 +1847,16 @@ const runCommand = async () => { loadBrokenLinks(), loadImageAudit(), loadSEOAudit(), - loadOrphans(), loadAudit(), + ]; + if (siteSupportsOrphans(site)) { + reloadTasks.push(loadOrphans()); + } else { + state.orphans = []; + renderOrphans(); + } + await Promise.all([ + ...reloadTasks, ]); } catch (err) { appendOutput(`Error: ${err.message}`); @@ -1888,6 +1954,11 @@ const wireActions = () => { }); byId("refresh-pending").addEventListener("click", loadPending); byId("refresh-ideas").addEventListener("click", loadIdeas); + setOutreachFilter(state.outreachStatus); + byId("outreach-status-filter").addEventListener("change", async (e) => { + setOutreachFilter(e.target.value); + await loadOutreach(); + }); byId("refresh-outreach").addEventListener("click", loadOutreach); byId("refresh-newsletter").addEventListener("click", loadNewsletters); byId("refresh-broken-links").addEventListener("click", loadBrokenLinks); @@ -2050,16 +2121,25 @@ const wireActions = () => { byId("outreach-list").addEventListener("click", async (e) => { const view = e.target.getAttribute("data-outreach-view"); const del = e.target.getAttribute("data-outreach-delete"); + const statusID = e.target.getAttribute("data-outreach-status"); + const statusValue = e.target.getAttribute("data-status-value"); if (view) { - const res = await apiFetch("/api/v1/outreach?status=new"); - const item = (res.items || []).find((it) => it.id === view); + const item = (state.outreach || []).find((it) => it.id === view); if (item) { - showModalText( - "Outreach Suggestion", - `${item.reddit_url}\n\n${item.article_title}\n${item.article_url}\n\n${item.suggested_response}` - ); + showModalText( + "Outreach Suggestion", + `${item.reddit_url}\n\n${item.article_title}\n${item.article_url}\n\n${item.suggested_response}` + ); } } + if (statusID && statusValue) { + await apiFetch(`/api/v1/outreach/${statusID}/status`, { + method: "POST", + body: JSON.stringify({ status: statusValue }), + }); + await loadOutreach(); + await loadAudit(); + } if (del) { await apiFetch(`/api/v1/outreach/${del}/delete`, { method: "POST" }); await loadOutreach(); diff --git a/internal/api/ui/index.html b/internal/api/ui/index.html index 8b8b48e..3b42948 100644 --- a/internal/api/ui/index.html +++ b/internal/api/ui/index.html @@ -204,7 +204,16 @@

Outreach

- +
+ + +
diff --git a/internal/config/multi_test.go b/internal/config/multi_test.go index dd090c4..3c99719 100644 --- a/internal/config/multi_test.go +++ b/internal/config/multi_test.go @@ -49,6 +49,10 @@ sites: app_password: "${WP_APP_PASSWORD_AV}" scheduler: languages: ["en", "es"] + outreach: + subreddit: "kubernetes" + min_score: 40 + min_comments: 10 - id: "padre" name: "Padre Primerizo" data_base_path: "./data/sites/padre" @@ -58,6 +62,10 @@ sites: app_password: "${WP_APP_PASSWORD_PADRE}" scheduler: languages: ["es"] + outreach: + subreddit: "paternidad" + min_score: 5 + min_comments: 1 newsletter: enabled: false `) @@ -86,6 +94,12 @@ sites: if padre.Config.Newsletter.Enabled { t.Fatalf("expected newsletter disabled for padre") } + if padre.Config.Outreach.MinScore != 5 { + t.Fatalf("expected padre outreach min_score 5, got %d", padre.Config.Outreach.MinScore) + } + if padre.Config.Outreach.MinComments != 1 { + t.Fatalf("expected padre outreach min_comments 1, got %d", padre.Config.Outreach.MinComments) + } gotIdeas := filepath.Clean(padre.Config.Ideas.BasePath) expectedIdeas := filepath.Clean("./data/sites/padre/ideas") if gotIdeas != expectedIdeas { @@ -96,6 +110,23 @@ sites: if gotStorage != expectedStorage { t.Fatalf("expected storage base path %q, got %q", expectedStorage, gotStorage) } + + var av *SiteDefinition + for i := range set.Sites { + if set.Sites[i].ID == "av" { + av = &set.Sites[i] + break + } + } + if av == nil { + t.Fatalf("av site not found") + } + if av.Config.Outreach.MinScore != 40 { + t.Fatalf("expected av outreach min_score 40, got %d", av.Config.Outreach.MinScore) + } + if av.Config.Outreach.MinComments != 10 { + t.Fatalf("expected av outreach min_comments 10, got %d", av.Config.Outreach.MinComments) + } } func TestLoadConfigSetLegacy(t *testing.T) { diff --git a/internal/ideas/service.go b/internal/ideas/service.go index db60f33..85b72f2 100644 --- a/internal/ideas/service.go +++ b/internal/ideas/service.go @@ -3,6 +3,7 @@ package ideas import ( "context" "fmt" + "net/url" "strings" "time" @@ -165,6 +166,7 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou } filtered := make([]reddit.Post, 0, len(posts)) + seenCandidates := make(map[string]struct{}, len(posts)) for _, post := range posts { if post.Score < cfg.MinScore { continue @@ -172,12 +174,56 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou if post.NumComments < cfg.MinComments { continue } + link := post.PermalinkURL + if link == "" { + link = post.URL + } + key := canonicalRedditURL(link) + if key == "" { + continue + } + if _, exists := seenCandidates[key]; exists { + continue + } + seenCandidates[key] = struct{}{} filtered = append(filtered, post) } if len(filtered) == 0 { return nil, fmt.Errorf("no reddit posts met outreach threshold (min_score=%d, min_comments=%d)", cfg.MinScore, cfg.MinComments) } + existingSuggestions, err := s.outreach.List("all") + if err != nil { + return nil, fmt.Errorf("failed to list existing outreach suggestions: %w", err) + } + existingByRedditURL := make(map[string]struct{}, len(existingSuggestions)) + for _, item := range existingSuggestions { + key := canonicalRedditURL(item.RedditURL) + if key == "" { + continue + } + existingByRedditURL[key] = struct{}{} + } + + candidatePosts := make([]reddit.Post, 0, len(filtered)) + for _, post := range filtered { + link := post.PermalinkURL + if link == "" { + link = post.URL + } + key := canonicalRedditURL(link) + if key == "" { + continue + } + if _, exists := existingByRedditURL[key]; exists { + continue + } + candidatePosts = append(candidatePosts, post) + } + if len(candidatePosts) == 0 { + return nil, fmt.Errorf("no new outreach suggestions: all candidate reddit links were already suggested") + } + articles, err := s.wpClient.GetInternalPosts(ctx, "en") if err != nil { return nil, fmt.Errorf("failed to fetch internal posts: %w", err) @@ -200,7 +246,7 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou MaxSuggestions: cfg.MaxSuggestions, Articles: articleRefs, } - for _, post := range filtered { + for _, post := range candidatePosts { link := post.PermalinkURL if link == "" { link = post.URL @@ -230,7 +276,20 @@ func (s *Service) GenerateOutreachSuggestions(ctx context.Context) ([]*models.Ou } stored := make([]*models.OutreachSuggestion, 0, len(suggestions)) + seenGenerated := make(map[string]struct{}, len(suggestions)) for _, suggestion := range suggestions { + key := canonicalRedditURL(suggestion.RedditURL) + if key == "" { + continue + } + if _, exists := existingByRedditURL[key]; exists { + continue + } + if _, exists := seenGenerated[key]; exists { + continue + } + seenGenerated[key] = struct{}{} + suggestion.RedditURL = key suggestion.ID = uuid.New().String() suggestion.Status = "new" suggestion.CreatedAt = time.Now() @@ -257,6 +316,20 @@ func (s *Service) ListOutreach(status string) ([]*models.OutreachSuggestion, err return s.outreach.List(status) } +// UpdateOutreachStatus changes the status for an outreach suggestion. +func (s *Service) UpdateOutreachStatus(id, status string) (*models.OutreachSuggestion, error) { + if s.outreach == nil { + return nil, fmt.Errorf("outreach storage not configured") + } + status = strings.ToLower(strings.TrimSpace(status)) + switch status { + case "new", "drafted", "reviewed", "dismissed": + default: + return nil, fmt.Errorf("invalid outreach status: %s", status) + } + return s.outreach.UpdateStatus(id, status) +} + // DeleteOutreach removes an outreach suggestion. func (s *Service) DeleteOutreach(id string) error { if s.outreach == nil { @@ -314,17 +387,17 @@ func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostId continue } prompt, err := BuildArticlePrompt(ArticlePromptData{ - Language: languageLabel(lang), - IdeaTitle: idea.SEOTitle, - IdeaSummary: idea.Summary, - PrimaryKW: idea.PrimaryKW, - Sources: sourceURLs, - StyleSamples: styleSamples, - SiteName: s.profile.Name, - SiteURL: s.profile.URL, - SiteTopics: strings.Join(s.profile.Topics, ", "), - SiteAudience: s.profile.Audience, - SiteVoice: s.profile.Voice, + Language: languageLabel(lang), + IdeaTitle: idea.SEOTitle, + IdeaSummary: idea.Summary, + PrimaryKW: idea.PrimaryKW, + Sources: sourceURLs, + StyleSamples: styleSamples, + SiteName: s.profile.Name, + SiteURL: s.profile.URL, + SiteTopics: strings.Join(s.profile.Topics, ", "), + SiteAudience: s.profile.Audience, + SiteVoice: s.profile.Voice, ContentGuidelines: s.profile.ContentGuidelines, }) if err != nil { @@ -502,6 +575,34 @@ func normalizeLanguages(langs []string) []string { return out } +func canonicalRedditURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + u, err := url.Parse(trimmed) + if err != nil || u.Host == "" { + return strings.TrimRight(trimmed, "/") + } + host := strings.ToLower(u.Hostname()) + if strings.HasPrefix(host, "old.") { + host = strings.TrimPrefix(host, "old.") + } + if strings.HasPrefix(host, "www.") { + host = strings.TrimPrefix(host, "www.") + } + u.Scheme = "https" + u.Host = host + u.RawQuery = "" + u.Fragment = "" + cleanPath := strings.TrimRight(u.EscapedPath(), "/") + if cleanPath == "" { + cleanPath = "/" + } + u.Path = cleanPath + return u.String() +} + func languageLabel(code string) string { switch strings.ToLower(strings.TrimSpace(code)) { case "es": diff --git a/internal/ideas/service_test.go b/internal/ideas/service_test.go index a2b254a..2d00022 100644 --- a/internal/ideas/service_test.go +++ b/internal/ideas/service_test.go @@ -15,13 +15,19 @@ import ( "seo-optimizer/pkg/models" ) -type fakeLLM struct{} +type fakeLLM struct { + postIdeas []*models.PostIdea + outreachSuggestions []*models.OutreachSuggestion +} func (f *fakeLLM) OptimizePost(ctx context.Context, post *wordpress.Post, internalPosts []*wordpress.InternalPostReference, categories map[int]string, tags map[int]string, options *agent.OptimizeOptions) (*models.OptimizationResponse, error) { return nil, nil } func (f *fakeLLM) GeneratePostIdeas(ctx context.Context, prompt string) ([]*models.PostIdea, error) { + if f.postIdeas != nil { + return f.postIdeas, nil + } return []*models.PostIdea{ { SEOTitle: "Idea SEO", @@ -47,7 +53,10 @@ func (f *fakeLLM) GenerateArticleDraft(ctx context.Context, prompt string) (*mod } func (f *fakeLLM) GenerateOutreachSuggestions(ctx context.Context, prompt string) ([]*models.OutreachSuggestion, error) { - return nil, nil + if f.outreachSuggestions != nil { + return f.outreachSuggestions, nil + } + return []*models.OutreachSuggestion{}, nil } func (f *fakeLLM) GenerateNewsletterDraft(ctx context.Context, prompt string) (*models.NewsletterDraft, error) { @@ -61,17 +70,17 @@ func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) { "data": map[string]interface{}{ "children": []map[string]interface{}{ {"data": map[string]interface{}{ - "title": "Post A", - "url": "https://reddit.com/r/test/a", - "permalink": "/r/test/comments/a", - "selftext": "Body", - "author": "user", - "score": 50, + "title": "Post A", + "url": "https://reddit.com/r/test/a", + "permalink": "/r/test/comments/a", + "selftext": "Body", + "author": "user", + "score": 50, "num_comments": 10, - "created_utc": 1710000000, - "subreddit": "paternidad", - "is_self": true, - "stickied": false, + "created_utc": 1710000000, + "subreddit": "paternidad", + "is_self": true, + "stickied": false, }}, }, }, @@ -88,8 +97,8 @@ func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) { } posts := []map[string]interface{}{ { - "id": 1, - "title": map[string]interface{}{"rendered": "Sample"}, + "id": 1, + "title": map[string]interface{}{"rendered": "Sample"}, "excerpt": map[string]interface{}{"rendered": "Excerpt"}, "content": map[string]interface{}{"rendered": "Content"}, }, @@ -155,3 +164,168 @@ func TestGenerateDailyIdeasAndDraftsSpanishOnly(t *testing.T) { t.Fatalf("expected no English draft for es-only site") } } + +func TestGenerateOutreachSuggestionsDeduplicatesAndSkipsExistingLinks(t *testing.T) { + redditServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "children": []map[string]interface{}{ + {"data": map[string]interface{}{ + "title": "Thread A", + "url": "https://www.reddit.com/r/kubernetes/comments/abc/thread_a/", + "permalink": "/r/kubernetes/comments/abc/thread_a/", + "selftext": "Need help with cluster setup", + "author": "user-a", + "score": 100, + "num_comments": 20, + "created_utc": 1710000000, + "subreddit": "kubernetes", + "is_self": true, + "stickied": false, + }}, + {"data": map[string]interface{}{ + "title": "Thread B", + "url": "https://www.reddit.com/r/kubernetes/comments/def/thread_b/", + "permalink": "/r/kubernetes/comments/def/thread_b/", + "selftext": "How to debug networking", + "author": "user-b", + "score": 120, + "num_comments": 30, + "created_utc": 1710000500, + "subreddit": "kubernetes", + "is_self": true, + "stickied": false, + }}, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer redditServer.Close() + + wpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/wp-json/wp/v2/posts" { + w.WriteHeader(http.StatusNotFound) + return + } + posts := []map[string]interface{}{ + { + "id": 10, + "slug": "kubernetes-guide", + "link": "https://example.com/kubernetes-guide", + "title": map[string]interface{}{"rendered": "Kubernetes Guide"}, + "excerpt": map[string]interface{}{"rendered": "Guide excerpt"}, + "status": "publish", + }, + } + _ = json.NewEncoder(w).Encode(posts) + })) + defer wpServer.Close() + + redditClient := reddit.NewClient(redditServer.URL, nil) + wpClient := wordpress.NewClient(wpServer.URL, "user", "pass", 0, nil) + + ideasStore := storage.NewIdeaStorage(t.TempDir(), nil) + if err := ideasStore.Initialize(); err != nil { + t.Fatalf("init ideas storage: %v", err) + } + outreachStore := storage.NewOutreachStorage(t.TempDir(), nil) + if err := outreachStore.Initialize(); err != nil { + t.Fatalf("init outreach storage: %v", err) + } + + // Existing suggestion for thread B should prevent regeneration for the same Reddit URL. + if err := outreachStore.Save(&models.OutreachSuggestion{ + ID: "existing-b", + RedditURL: "https://www.reddit.com/r/kubernetes/comments/def/thread_b/", + RedditTitle: "Thread B", + ArticleTitle: "Existing Article", + ArticleURL: "https://example.com/existing", + SuggestedResponse: "Existing response", + Status: "new", + }); err != nil { + t.Fatalf("seed outreach suggestion: %v", err) + } + + ideasCfg := config.IdeasConfig{ + Enabled: true, + IntervalHours: 24, + Subreddit: "kubernetes", + Listing: "top", + TimeRange: "day", + MaxRedditPosts: 5, + MaxIdeas: 1, + StyleSamplePosts: 1, + MinScore: 1, + MinComments: 1, + BasePath: t.TempDir(), + } + outreachCfg := config.OutreachConfig{ + Enabled: true, + Subreddit: "kubernetes", + Listing: "top", + TimeRange: "day", + MaxRedditPosts: 5, + MaxSuggestions: 5, + MinScore: 1, + MinComments: 1, + MaxArticles: 10, + BasePath: t.TempDir(), + } + profile := config.SiteProfile{ + Name: "Test Site", + URL: "https://example.com", + Topics: []string{"Kubernetes"}, + Audience: "Engineers", + Voice: "Technical", + } + + llm := &fakeLLM{ + outreachSuggestions: []*models.OutreachSuggestion{ + { + RedditURL: "https://www.reddit.com/r/kubernetes/comments/abc/thread_a/", + RedditTitle: "Thread A", + ArticleTitle: "Kubernetes Guide", + ArticleURL: "https://example.com/kubernetes-guide", + SuggestedResponse: "Helpful answer A.", + }, + { + // Duplicate of Thread A using a different URL shape should be deduplicated. + RedditURL: "https://old.reddit.com/r/kubernetes/comments/abc/thread_a/?utm_source=foo", + RedditTitle: "Thread A", + ArticleTitle: "Kubernetes Guide", + ArticleURL: "https://example.com/kubernetes-guide", + SuggestedResponse: "Helpful answer A duplicate.", + }, + { + // Existing thread should be skipped. + RedditURL: "https://www.reddit.com/r/kubernetes/comments/def/thread_b/", + RedditTitle: "Thread B", + ArticleTitle: "Kubernetes Guide", + ArticleURL: "https://example.com/kubernetes-guide", + SuggestedResponse: "Helpful answer B.", + }, + }, + } + + svc := NewService(wpClient, llm, redditClient, ideasStore, ideasCfg, outreachStore, outreachCfg, profile, []string{"en"}, true, nil) + + generated, err := svc.GenerateOutreachSuggestions(context.Background()) + if err != nil { + t.Fatalf("GenerateOutreachSuggestions failed: %v", err) + } + if len(generated) != 1 { + t.Fatalf("expected 1 generated outreach suggestion, got %d", len(generated)) + } + if generated[0].RedditURL != "https://reddit.com/r/kubernetes/comments/abc/thread_a" { + t.Fatalf("unexpected canonical reddit url: %s", generated[0].RedditURL) + } + + all, err := outreachStore.List("all") + if err != nil { + t.Fatalf("list outreach all: %v", err) + } + if len(all) != 2 { + t.Fatalf("expected 2 total outreach suggestions (seed + new), got %d", len(all)) + } +} diff --git a/internal/storage/outreach_storage.go b/internal/storage/outreach_storage.go index 4ce34cf..0447fb0 100644 --- a/internal/storage/outreach_storage.go +++ b/internal/storage/outreach_storage.go @@ -37,6 +37,7 @@ func (s *OutreachStorage) Initialize() error { dirs := []string{ s.basePath, filepath.Join(s.basePath, "new"), + filepath.Join(s.basePath, "drafted"), filepath.Join(s.basePath, "reviewed"), filepath.Join(s.basePath, "dismissed"), } @@ -85,6 +86,14 @@ func (s *OutreachStorage) Save(suggestion *models.OutreachSuggestion) error { // List returns suggestions by status. func (s *OutreachStorage) List(status string) ([]*models.OutreachSuggestion, error) { + status = strings.ToLower(strings.TrimSpace(status)) + if status == "" { + status = "new" + } + if status == "all" { + return s.listAll() + } + dir := filepath.Join(s.basePath, status) files, err := os.ReadDir(dir) if err != nil { @@ -117,6 +126,22 @@ func (s *OutreachStorage) List(status string) ([]*models.OutreachSuggestion, err return suggestions, nil } +func (s *OutreachStorage) listAll() ([]*models.OutreachSuggestion, error) { + statuses := []string{"new", "drafted", "reviewed", "dismissed"} + suggestions := make([]*models.OutreachSuggestion, 0) + for _, status := range statuses { + items, err := s.List(status) + if err != nil { + return nil, err + } + suggestions = append(suggestions, items...) + } + sort.Slice(suggestions, func(i, j int) bool { + return suggestions[i].CreatedAt.After(suggestions[j].CreatedAt) + }) + return suggestions, nil +} + // Delete removes a suggestion by ID. func (s *OutreachStorage) Delete(id string) error { suggestion, err := s.Get(id) @@ -136,7 +161,7 @@ func (s *OutreachStorage) Delete(id string) error { // Get retrieves a suggestion by ID. func (s *OutreachStorage) Get(id string) (*models.OutreachSuggestion, error) { - statuses := []string{"new", "reviewed", "dismissed"} + statuses := []string{"new", "drafted", "reviewed", "dismissed"} for _, status := range statuses { dir := filepath.Join(s.basePath, status) files, err := os.ReadDir(dir)