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