[Backend] Archiving a campaign does not stop it creating sessions or sending Discord reminders #479
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM
Found while implementing #405, where the proposed fix was to route campaign deletion through the existing archive feature. That turned out not to work, and the reason is this issue.
What archiving does and does not do
Archiving is not a no-op, and this issue is not a claim that it is. It does exactly what its own UI text promises — "hidden from the default active dashboard list, but its history stays available here for reference and export":
webapp/frontend/src/pages/Dashboard.jsx:274-275splits active from archivedwebapp/frontend/src/pages/CampaignDetail.jsx:1400shows the archived bannerwebapp/backend/app/services/campaign_service.py:236blocks join-by-inviteWhat it does not do is suppress anything in the backend.
is_archivedappears in exactly three places server-side — the model (models/campaign.py:115), the schemas (schemas/campaign.py:250,288), andcampaign_service(the archive/restore functions plus that one join guard). There are zero references inapp/tasks/, inrouters/bot.py, or anywhere in thebot/package.So archiving is a display-level lifecycle state. Every scheduled task treats an archived campaign as fully live.
The root is session creation, not the reminders
The reminders are the symptom.
materialize_session_series(app/tasks/reminder_tasks.py, hourly Beat) selects:with no campaign check at all. An archived campaign with an active recurring series therefore keeps materialising new confirmed sessions, 28 days ahead, every hour. Those sessions are real rows, so
poll_session_remindersthen does its job on them correctly.Nothing prevents archiving a campaign that has a series or future sessions —
archive_campaignonly guards against double-archiving.Failure scenario
A GM archives a campaign at the end of a season, exactly as the feature invites them to. The group's weekly recurring series keeps generating sessions, and Discord keeps posting reminders for a campaign nobody is playing — 7-day, 24-hour and 1-hour pings, plus at-risk warnings when nobody RSVPs to a session that was never meant to exist. The GM's only recourse is to hunt down and deactivate the series, or delete the campaign outright, which is precisely the destructive action archiving exists to be an alternative to.
Proposed fix, and the scoping that matters
Two changes, in this order:
materialize_session_seriesshould skip series whose campaign is archived. This is the actual fix — no new sessions means no reminders for them.Do not implement (2) by adding the archived check to
campaign_service.get_live_campaign. That helper was added in #405 and is called at all 11 campaign-resolution sites inreminder_tasks.py, but those sites are not all outbound notifications — and suppressing the wrong ones loses data rather than noise:process_audio's campaign lookupgenerate_journal_entrywarn_before_audio_deletionThe suppression wants a separate predicate applied only to the genuinely outbound sites (session reminders, at-risk warnings, vote notifications, recap email, attendance posts), not a blanket filter on campaign resolution. That distinction is the substance of this issue; getting it wrong converts an annoyance into silent data loss.
This is deliberately not how #405 handled deletion. A trashed campaign is invisible to everyone including its GM, so a blanket filter is correct there. An archived campaign stays fully readable, so only its outbound behaviour should change.
Acceptance criteria
Also in scope: trashed campaigns keep materialising sessions
Found while implementing #405 and deliberately left out of PR #478, because it belongs with this issue —
materialize_session_seriesis the one function that needs a lifecycle check for both states, and settling them separately would mean touching it twice with two different predicates.#405 routed the scheduled tasks' campaign lookups through
campaign_service.get_live_campaign, which returnsNonefor a trashed campaign so theif not campaign: continueguard those tasks already had does the suppression. But the series materialiser does not use that lookup as a gate — it resolves the campaign only for the timezone:So a trashed campaign with an active series keeps generating sessions too, falling back to
tz_name = Nonerather than skipping.Why it was left rather than fixed in #478
It is close to harmless today, and the argument for leaving it is real enough to record rather than assume away:
So it is rows written for a campaign scheduled for deletion, not user-visible harm. The one wrinkle: a restored campaign would hold sessions that never had reminders sent, which is a quiet inconsistency rather than a bug.
What to do here
Whatever gate this issue adds for archived campaigns, apply the trashed case in the same place. Concretely, the series selection wants to exclude both — join
Campaignand filterdeleted_at IS NULLandis_archived IS false— rather than resolving the campaign afterwards purely for a timezone.Note the two states want the same treatment here, which is unusual: elsewhere in #405 they differ sharply (a trashed campaign is invisible to everyone; an archived one stays fully readable). Session creation is the one behaviour neither state should have.
Extra acceptance criterion
claude-bot referenced this issue2026-09-06 00:33:37 +00:00
Picking this up as part of v4.3.0 phase 1 (#514), shipping early as v4.2.3. Following the scoping in the body exactly: materialisation skips archived campaigns; a separate outbound-only predicate at the reminder, at-risk, vote, recap-email and attendance sites;
process_audio, journal generation and the audio-deletion warning stay live with a test each. For already-materialised future sessions the decision is warn, not refuse: archiving keeps them and reports how many will go quiet.Fixed in PR #516 (merged), shipping in v4.2.3.
The root, as the issue called it, was session creation.
materialize_session_seriesnow joins the campaign it was already fetching one row at a time for a timezone, and skips a series whose campaign is archived, or trashed, which turned out to share the hole: the oldget_live_campaignreturnedNonefor a trashed campaign and the loop materialised into it anyway. The series is not deactivated, so un-archiving resumes with nothing to backfill (rolling window plusON CONFLICT DO NOTHING).Suppression is a separate predicate,
campaign_service.campaign_accepts_outbound, applied at exactly five sites: session reminder, at-risk warning, three-day vote nudge, recap email, end-of-session attendance post, each logging the campaign id once at info. The line drawn is unprompted, not Discord-facing: a vote someone casts and a card a GM reveals still say what they were asked to say, because swallowing a button press would be a worse bug than this one. Two placements matter and are asserted on: the reminder check precedes theSessionReminderSentclaim and the vote check precedes thevote_reminder_sent_atstamp, so an archived campaign never burns a marker it did not use.process_audio,generate_journal_entryandwarn_before_audio_deletionare untouched and have a test each. To prove those tests bite, the exact wrong change this issue warns about (or campaign.is_archivedinsideget_live_campaign) was applied temporarily: those three go red and nothing else does.Decision on already-materialised future sessions: warn, not refuse. Deleting them would destroy rows a group may have notes and attendance on; refusing would make archiving fail on exactly the campaigns it exists for, since stopping mid-series is the common case. They stay and go quiet.
POST /campaigns/{id}/archivenow returnssilenced_future_sessions(additive, on a subclass ofCampaignResponse): the count of confirmed or in-progress sessions still ahead, which is precisely the set the poller would have announced. Also recorded in the audit context. No UI here; #382 can render it.No migration. No
/api/bot/*change, so noBOT_CONTRACT_VERSIONbump. Newtests/test_archived_campaign_outbound.py(13 tests); full backend suite 2349 passed / 13 skipped; ruff clean.One judgment call flagged for a second opinion:
send_vote_notification(the ping when a player actually casts a vote,routers/votes.py) is deliberately not suppressed, for the human-in-the-loop reason above. If the intent was to silence that too, it is a three-line change plus inverting one test that exists so the choice stays deliberate.