From a2620570fae37de11077cc065f2f999b924151b3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 6 May 2026 08:52:21 -0700 Subject: [PATCH] feat(dupe-guard): add max_posts per-sub cap to prevent one-shot intro campaigns from re-posting Adds max_posts INTEGER to campaign_subs (NULL = unlimited/evergreen). Adds successful_post_count() query counting lifetime success records. poster.py checks max_posts before the 7-day rolling dupe guard. Root cause: campaign 2 fired 8 days after the last post (just outside the 7-day window), allowing a duplicate r/opensource pitch. Fix: set max_posts=1 on intro campaigns so the lifetime cap fires regardless of window. --- app/db/migrations/018_campaign_subs_max_posts.sql | 2 ++ app/db/store.py | 8 ++++++++ app/services/poster.py | 11 +++++++++++ 3 files changed, 21 insertions(+) create mode 100644 app/db/migrations/018_campaign_subs_max_posts.sql diff --git a/app/db/migrations/018_campaign_subs_max_posts.sql b/app/db/migrations/018_campaign_subs_max_posts.sql new file mode 100644 index 0000000..e1bce93 --- /dev/null +++ b/app/db/migrations/018_campaign_subs_max_posts.sql @@ -0,0 +1,2 @@ +-- Add per-sub post cap; NULL = unlimited (evergreen). Set max_posts=1 for one-shot intro campaigns. +ALTER TABLE campaign_subs ADD COLUMN max_posts INTEGER; diff --git a/app/db/store.py b/app/db/store.py index 5de8b6c..a22c09c 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -319,6 +319,14 @@ class Store: ) return row is not None + def successful_post_count(self, campaign_id: int, target: str) -> int: + """Return lifetime count of successful posts for this campaign+target pair.""" + row = self._fetchone( + "SELECT COUNT(*) AS n FROM posts WHERE campaign_id = ? AND target = ? AND status = 'success'", + (campaign_id, target), + ) + return row["n"] if row else 0 + # ------------------------------------------------------------------ # # Sub rules # ------------------------------------------------------------------ # diff --git a/app/services/poster.py b/app/services/poster.py index e17bf66..7bbbc64 100644 --- a/app/services/poster.py +++ b/app/services/poster.py @@ -53,6 +53,17 @@ def _run_post(db_path: str, campaign_id: int, target: str, ) return {"skipped": True, "reason": f"occurrence {occurrence_str} not today"} + # Per-sub post cap (max_posts=None means unlimited) + max_posts = sub_row.get("max_posts") + if max_posts is not None: + count = store.successful_post_count(campaign_id, target) + if count >= max_posts: + logger.info( + "Skipping %s / %s — reached max_posts=%d (posted %d time(s))", + campaign_id, target, max_posts, count, + ) + return {"skipped": True, "reason": f"max_posts={max_posts} reached for {target!r}"} + # Dupe guard (opt-out allowed per strategy) if strategy.supports_dupe_guard() and store.already_posted_this_week(campaign_id, target): return {"skipped": True, "reason": f"already posted to {target!r} this week"}