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.
This commit is contained in:
pyr0ball 2026-05-06 08:52:21 -07:00
parent 01e5990f58
commit a2620570fa
3 changed files with 21 additions and 0 deletions

View file

@ -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;

View file

@ -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
# ------------------------------------------------------------------ #

View file

@ -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"}