From 4c230caa326ff672afd628370e72ae4f56918567 Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 4 Jun 2026 14:05:58 +0200 Subject: [PATCH] fix(campaigns): start_date modal + hide future-campaign tests from queue Backend: activate endpoint returns 409 with structured warning when start_date is in the future; accepts force=true to bypass. test_crud_service: always excludes tests from draft campaigns with future start_date so they do not appear in the team queue prematurely. Frontend: catches 409 on activate and shows amber confirmation modal with Keep scheduled / Activate now anyway options. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/campaigns.py | 29 ++++++++------ backend/app/services/test_crud_service.py | 25 +++++++++++- frontend/src/api/campaigns.ts | 8 +++- frontend/src/pages/CampaignDetailPage.tsx | 48 +++++++++++++++++++++-- 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index f31071e..5f26754 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -273,28 +273,33 @@ def remove_test_from_campaign( @router.post("/{campaign_id}/activate") def activate_campaign( campaign_id: str, + force: bool = Query(False, description="Activate even if start_date is in the future"), db: Session = Depends(get_db), current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Activate a campaign, moving it from draft to active. - If the campaign has a start_date in the future, manual activation is blocked — - the campaign will be auto-activated by the scheduler when the date arrives. + If the campaign has a start_date in the future and force=False, returns a 409 + with a warning so the frontend can show a confirmation modal. If force=True, + activates immediately regardless of start_date. """ - # Guard: start_date must have been reached before manual activation + from fastapi import HTTPException campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first() - if campaign_obj and campaign_obj.start_date: + if campaign_obj and campaign_obj.start_date and not force: now = datetime.utcnow() if campaign_obj.start_date > now: - from fastapi import HTTPException raise HTTPException( - status_code=422, - detail=( - f"This campaign is scheduled to start on " - f"{campaign_obj.start_date.strftime('%Y-%m-%d')}. " - f"It will be activated automatically on that date. " - f"To activate it now, remove the start date first." - ), + status_code=409, + detail={ + "code": "start_date_in_future", + "start_date": campaign_obj.start_date.strftime("%Y-%m-%d"), + "message": ( + f"This campaign is scheduled to start on " + f"{campaign_obj.start_date.strftime('%d %b %Y')}. " + f"It will activate automatically on that date. " + f"Do you want to activate it now anyway?" + ), + }, ) with UnitOfWork(db) as uow: diff --git a/backend/app/services/test_crud_service.py b/backend/app/services/test_crud_service.py index 6864f71..03e61d1 100644 --- a/backend/app/services/test_crud_service.py +++ b/backend/app/services/test_crud_service.py @@ -19,7 +19,7 @@ from app.models.enums import TestState from app.models.technique import Technique from app.models.test import Test from app.models.test_template import TestTemplate -from app.models.campaign import CampaignTest +from app.models.campaign import Campaign, CampaignTest from app.models.audit import AuditLog from app.utils import escape_like @@ -36,7 +36,12 @@ def list_tests( offset: int = 0, limit: int = 50, ) -> list[Test]: - """Return a paginated list of tests with optional filters.""" + """Return a paginated list of tests with optional filters. + + Tests that belong to a campaign still in 'draft' status AND with a + start_date in the future are always excluded — they should not appear + in the team's queue until the campaign is activated on its start date. + """ query = db.query(Test).options(joinedload(Test.technique)) if state: @@ -61,6 +66,22 @@ def list_tests( linked = db.query(CampaignTest.test_id).distinct().subquery() query = query.filter(~Test.id.in_(linked)) + # Always hide tests from scheduled campaigns that haven't started yet. + # A "scheduled-but-not-yet-active" campaign = draft status + start_date in future. + now = datetime.utcnow() + future_draft_tests = ( + db.query(CampaignTest.test_id) + .join(Campaign, Campaign.id == CampaignTest.campaign_id) + .filter( + Campaign.status == "draft", + Campaign.start_date.isnot(None), + Campaign.start_date > now, + ) + .distinct() + .subquery() + ) + query = query.filter(~Test.id.in_(future_draft_tests)) + return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all() diff --git a/frontend/src/api/campaigns.ts b/frontend/src/api/campaigns.ts index 701f8d0..b815033 100644 --- a/frontend/src/api/campaigns.ts +++ b/frontend/src/api/campaigns.ts @@ -134,8 +134,12 @@ export async function removeTestFromCampaign( } /** Activate a campaign. */ -export async function activateCampaign(campaignId: string): Promise { - const { data } = await client.post(`/campaigns/${campaignId}/activate`); +export async function activateCampaign( + campaignId: string, + options?: { force?: boolean }, +): Promise { + const params = options?.force ? "?force=true" : ""; + const { data } = await client.post(`/campaigns/${campaignId}/activate${params}`); return data; } diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index 4f5b9c9..9a2926f 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -68,6 +68,8 @@ export default function CampaignDetailPage() { const [showAddTestModal, setShowAddTestModal] = useState(false); // 0 = hidden, 1 = first confirmation, 2 = ask about tests const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0); + // Start-date confirmation modal — shown when campaign has a future start_date + const [startDateWarning, setStartDateWarning] = useState(null); const showToast = (message: string, type: "success" | "error") => { setToast({ message, type }); @@ -89,12 +91,21 @@ export default function CampaignDetailPage() { }); const activateMutation = useMutation({ - mutationFn: () => activateCampaign(campaignId!), + mutationFn: (force = false) => activateCampaign(campaignId!, force ? { force: true } : undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); + setStartDateWarning(null); showToast("Campaign activated", "success"); }, - onError: (err: Error) => showToast(err.message, "error"), + onError: (err: unknown) => { + // 409 = future start_date warning → show confirmation modal instead of toast + const axiosErr = err as { response?: { status?: number; data?: { message?: string; start_date?: string } } }; + if (axiosErr?.response?.status === 409 && axiosErr.response.data?.message) { + setStartDateWarning(axiosErr.response.data.message); + } else { + showToast((err as Error).message, "error"); + } + }, }); const completeMutation = useMutation({ @@ -297,7 +308,7 @@ export default function CampaignDetailPage() { )} {canManage && campaign.status === "draft" && ( + + + + + )} + {/* Toast notification */} {toast && (