feat(campaigns): campaign start date — scheduled activation, Jira start_date
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
DB: migration b047 adds start_date (DateTime nullable) + index to campaigns. Backend: - Campaign model: start_date field - CampaignCreate/Update schemas: accept start_date (ISO string) - CRUD service: persist + serialize start_date in both serializers - Activation endpoint: blocks manual activation if start_date is in the future (campaign will auto-activate via scheduler) - Scheduler: new hourly job _run_scheduled_campaign_activation — finds draft campaigns with start_date <= now, activates them, creates Jira tickets, notifies red_tech team - Jira: campaign + test tickets now include JIRA_START_DATE_FIELD (configurable, default customfield_10015). Campaign uses start_date if set, else created_at. Tests inherit campaign start_date. - config.py: JIRA_START_DATE_FIELD setting Frontend: - Campaign type: start_date field on Campaign + CampaignSummary - CampaignCreatePayload: start_date optional field - Create form: date picker with min=today, warning message explaining behavior - Campaign detail header: start_date badge showing days remaining or started date Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
27
backend/alembic/versions/b047_campaign_start_date.py
Normal file
27
backend/alembic/versions/b047_campaign_start_date.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add start_date to campaigns.
|
||||||
|
|
||||||
|
Revision ID: b047
|
||||||
|
Revises: b046
|
||||||
|
Create Date: 2026-06-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b047"
|
||||||
|
down_revision = "b046"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"campaigns",
|
||||||
|
sa.Column("start_date", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_campaigns_start_date", "campaigns", ["start_date"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_campaigns_start_date", table_name="campaigns")
|
||||||
|
op.drop_column("campaigns", "start_date")
|
||||||
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
|
|||||||
JIRA_DEFAULT_PROJECT: str = ""
|
JIRA_DEFAULT_PROJECT: str = ""
|
||||||
JIRA_ISSUE_TYPE_TEST: str = "Task" # tests (campaign or standalone)
|
JIRA_ISSUE_TYPE_TEST: str = "Task" # tests (campaign or standalone)
|
||||||
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" # campaigns (under Initiative)
|
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" # campaigns (under Initiative)
|
||||||
|
# Jira custom field ID for "Start date" — Jira Cloud team-managed: customfield_10015
|
||||||
|
# Override with the correct field ID for your Jira instance if different.
|
||||||
|
JIRA_START_DATE_FIELD: str = "customfield_10015"
|
||||||
|
|
||||||
# ── Tempo Integration ─────────────────────────────────────────────
|
# ── Tempo Integration ─────────────────────────────────────────────
|
||||||
TEMPO_ENABLED: bool = False
|
TEMPO_ENABLED: bool = False
|
||||||
|
|||||||
@@ -101,6 +101,96 @@ def _run_recurring_campaigns() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduled_campaign_activation() -> None:
|
||||||
|
"""Auto-activate campaigns whose start_date has arrived.
|
||||||
|
|
||||||
|
Finds all campaigns in 'draft' state with a start_date <= now,
|
||||||
|
activates them, creates Jira tickets, and notifies the red_tech team.
|
||||||
|
Runs every hour so campaigns activate within ~1 hour of their scheduled time.
|
||||||
|
"""
|
||||||
|
logger.info("Scheduled campaign auto-activation check starting...")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
from app.models.campaign import Campaign
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.campaign_crud_service import activate_campaign as _activate
|
||||||
|
from app.services.notification_service import notify_role
|
||||||
|
from app.services.audit_service import log_action
|
||||||
|
|
||||||
|
now = _dt.utcnow()
|
||||||
|
due_campaigns = (
|
||||||
|
db.query(Campaign)
|
||||||
|
.filter(
|
||||||
|
Campaign.status == "draft",
|
||||||
|
Campaign.start_date != None, # noqa: E711
|
||||||
|
Campaign.start_date <= now,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
activated = 0
|
||||||
|
for campaign in due_campaigns:
|
||||||
|
try:
|
||||||
|
_activate(db, str(campaign.id))
|
||||||
|
notify_role(
|
||||||
|
db,
|
||||||
|
role="red_tech",
|
||||||
|
type="campaign_activated",
|
||||||
|
title="Campaign auto-activated",
|
||||||
|
message=f'Campaign "{campaign.name}" has been automatically activated on its scheduled start date.',
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=None,
|
||||||
|
action="auto_activate_campaign",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
details={"name": campaign.name, "start_date": str(campaign.start_date)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create Jira tickets non-fatally
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import (
|
||||||
|
auto_create_campaign_issue,
|
||||||
|
auto_create_test_issue,
|
||||||
|
get_campaign_jira_key,
|
||||||
|
get_test_jira_key,
|
||||||
|
)
|
||||||
|
# Use first admin user as actor for Jira auth
|
||||||
|
admin_user = db.query(User).filter(User.role == "admin").first()
|
||||||
|
if admin_user:
|
||||||
|
db.refresh(campaign)
|
||||||
|
campaign_jira_key = get_campaign_jira_key(db, str(campaign.id))
|
||||||
|
if not campaign_jira_key:
|
||||||
|
campaign_jira_key = auto_create_campaign_issue(db, campaign, admin_user)
|
||||||
|
if campaign_jira_key:
|
||||||
|
for ct in campaign.campaign_tests:
|
||||||
|
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||||
|
auto_create_test_issue(
|
||||||
|
db, ct.test, admin_user,
|
||||||
|
parent_ticket_override=campaign_jira_key,
|
||||||
|
campaign_start_date=campaign.start_date,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Jira auto-create failed for auto-activated campaign %s", campaign.id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
activated += 1
|
||||||
|
logger.info("Auto-activated campaign %s (%s)", campaign.id, campaign.name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to auto-activate campaign %s", campaign.id)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
logger.info("Campaign auto-activation check finished — activated %d campaigns", activated)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Campaign auto-activation job failed")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_intel_scan() -> None:
|
def _run_intel_scan() -> None:
|
||||||
"""Execute an intel scan inside its own DB session."""
|
"""Execute an intel scan inside its own DB session."""
|
||||||
logger.info("Scheduled intel scan job starting...")
|
logger.info("Scheduled intel scan job starting...")
|
||||||
@@ -291,6 +381,14 @@ def start_scheduler() -> None:
|
|||||||
name="Weekly coverage snapshot (Sundays 00:00)",
|
name="Weekly coverage snapshot (Sundays 00:00)",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_scheduled_campaign_activation,
|
||||||
|
trigger="interval",
|
||||||
|
hours=1,
|
||||||
|
id="scheduled_campaign_activation",
|
||||||
|
name="Auto-activate campaigns on start_date (hourly)",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_recurring_campaigns,
|
_run_recurring_campaigns,
|
||||||
trigger="interval",
|
trigger="interval",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class Campaign(Base):
|
|||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
|
||||||
scheduled_at = Column(DateTime, nullable=True)
|
scheduled_at = Column(DateTime, nullable=True)
|
||||||
completed_at = Column(DateTime, nullable=True)
|
completed_at = Column(DateTime, nullable=True)
|
||||||
target_platform = Column(String, nullable=True)
|
target_platform = Column(String, nullable=True)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class CampaignCreate(BaseModel):
|
|||||||
target_platform: Optional[str] = None
|
target_platform: Optional[str] = None
|
||||||
tags: Optional[list[str]] = Field(default_factory=list)
|
tags: Optional[list[str]] = Field(default_factory=list)
|
||||||
scheduled_at: Optional[str] = None
|
scheduled_at: Optional[str] = None
|
||||||
|
start_date: Optional[str] = None # ISO date — campaign won't activate before this
|
||||||
|
|
||||||
class CampaignUpdate(BaseModel):
|
class CampaignUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -62,6 +63,7 @@ class CampaignUpdate(BaseModel):
|
|||||||
target_platform: Optional[str] = None
|
target_platform: Optional[str] = None
|
||||||
tags: Optional[list[str]] = None
|
tags: Optional[list[str]] = None
|
||||||
scheduled_at: Optional[str] = None
|
scheduled_at: Optional[str] = None
|
||||||
|
start_date: Optional[str] = None # ISO date — can be updated while still in draft
|
||||||
|
|
||||||
class AddTestPayload(BaseModel):
|
class AddTestPayload(BaseModel):
|
||||||
test_id: str
|
test_id: str
|
||||||
@@ -125,6 +127,7 @@ def create_campaign(
|
|||||||
target_platform=payload.target_platform,
|
target_platform=payload.target_platform,
|
||||||
tags=payload.tags,
|
tags=payload.tags,
|
||||||
scheduled_at=payload.scheduled_at,
|
scheduled_at=payload.scheduled_at,
|
||||||
|
start_date=payload.start_date,
|
||||||
)
|
)
|
||||||
campaign_id = result["id"]
|
campaign_id = result["id"]
|
||||||
log_action(
|
log_action(
|
||||||
@@ -273,7 +276,27 @@ def activate_campaign(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
):
|
):
|
||||||
"""Activate a campaign, moving it from draft to active."""
|
"""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.
|
||||||
|
"""
|
||||||
|
# Guard: start_date must have been reached before manual activation
|
||||||
|
campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||||
|
if campaign_obj and campaign_obj.start_date:
|
||||||
|
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."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
campaign = crud_activate(db, campaign_id)
|
campaign = crud_activate(db, campaign_id)
|
||||||
notify_role(
|
notify_role(
|
||||||
@@ -314,6 +337,7 @@ def activate_campaign(
|
|||||||
auto_create_test_issue(
|
auto_create_test_issue(
|
||||||
db, ct.test, current_user,
|
db, ct.test, current_user,
|
||||||
parent_ticket_override=campaign_jira_key,
|
parent_ticket_override=campaign_jira_key,
|
||||||
|
campaign_start_date=campaign.start_date,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ def serialize_campaign(db: Session, campaign: Campaign) -> dict:
|
|||||||
"threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
|
"threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
|
||||||
"threat_actor_name": actor.name if actor else None,
|
"threat_actor_name": actor.name if actor else None,
|
||||||
"created_by": str(campaign.created_by) if campaign.created_by else None,
|
"created_by": str(campaign.created_by) if campaign.created_by else None,
|
||||||
|
"start_date": campaign.start_date.isoformat() if campaign.start_date else None,
|
||||||
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
||||||
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
|
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
|
||||||
"target_platform": campaign.target_platform,
|
"target_platform": campaign.target_platform,
|
||||||
@@ -100,6 +101,7 @@ def serialize_campaign_summary(db: Session, campaign: Campaign) -> dict:
|
|||||||
"status": campaign.status,
|
"status": campaign.status,
|
||||||
"threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
|
"threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
|
||||||
"threat_actor_name": actor.name if actor else None,
|
"threat_actor_name": actor.name if actor else None,
|
||||||
|
"start_date": campaign.start_date.isoformat() if campaign.start_date else None,
|
||||||
"target_platform": campaign.target_platform,
|
"target_platform": campaign.target_platform,
|
||||||
"tags": campaign.tags or [],
|
"tags": campaign.tags or [],
|
||||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||||
@@ -160,6 +162,7 @@ def create_campaign(
|
|||||||
target_platform: Optional[str] = None,
|
target_platform: Optional[str] = None,
|
||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
scheduled_at: Optional[str] = None,
|
scheduled_at: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new campaign. Does not commit; caller commits."""
|
"""Create a new campaign. Does not commit; caller commits."""
|
||||||
campaign = Campaign(
|
campaign = Campaign(
|
||||||
@@ -171,6 +174,7 @@ def create_campaign(
|
|||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
created_by=creator_id,
|
created_by=creator_id,
|
||||||
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
|
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
|
||||||
|
start_date=datetime.fromisoformat(start_date) if start_date else None,
|
||||||
)
|
)
|
||||||
db.add(campaign)
|
db.add(campaign)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -213,6 +217,8 @@ def update_campaign(
|
|||||||
|
|
||||||
if "scheduled_at" in fields and fields["scheduled_at"]:
|
if "scheduled_at" in fields and fields["scheduled_at"]:
|
||||||
fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
|
fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
|
||||||
|
if "start_date" in fields and fields["start_date"]:
|
||||||
|
fields["start_date"] = datetime.fromisoformat(fields["start_date"])
|
||||||
|
|
||||||
for field, value in fields.items():
|
for field, value in fields.items():
|
||||||
setattr(campaign, field, value)
|
setattr(campaign, field, value)
|
||||||
|
|||||||
@@ -407,6 +407,11 @@ def auto_create_campaign_issue(
|
|||||||
"customfield_10011": campaign.name,
|
"customfield_10011": campaign.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Set start date: use campaign.start_date if set, otherwise today
|
||||||
|
effective_start = campaign.start_date or campaign.created_at
|
||||||
|
if effective_start:
|
||||||
|
fields[settings.JIRA_START_DATE_FIELD] = effective_start.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Nest under the configured parent ticket (Initiative, e.g. OFS-20795)
|
# Nest under the configured parent ticket (Initiative, e.g. OFS-20795)
|
||||||
if parent_ticket:
|
if parent_ticket:
|
||||||
fields["parent"] = {"key": parent_ticket}
|
fields["parent"] = {"key": parent_ticket}
|
||||||
@@ -446,6 +451,7 @@ def auto_create_test_issue(
|
|||||||
*,
|
*,
|
||||||
technique: Optional[Technique] = None,
|
technique: Optional[Technique] = None,
|
||||||
parent_ticket_override: Optional[str] = None,
|
parent_ticket_override: Optional[str] = None,
|
||||||
|
campaign_start_date=None, # datetime | None — inherited from campaign when available
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Create a Jira issue for *test* and store the link.
|
"""Create a Jira issue for *test* and store the link.
|
||||||
|
|
||||||
@@ -496,6 +502,12 @@ def auto_create_test_issue(
|
|||||||
"customfield_10309": f"{{code}}{poc}{{code}}",
|
"customfield_10309": f"{{code}}{poc}{{code}}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Inherit campaign start date if available, otherwise use today
|
||||||
|
from datetime import date as _date
|
||||||
|
effective_start = campaign_start_date or _date.today()
|
||||||
|
if hasattr(effective_start, "strftime"):
|
||||||
|
fields[settings.JIRA_START_DATE_FIELD] = effective_start.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
if parent:
|
if parent:
|
||||||
fields["parent"] = {"key": parent}
|
fields["parent"] = {"key": parent}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface Campaign {
|
|||||||
threat_actor_id: string | null;
|
threat_actor_id: string | null;
|
||||||
threat_actor_name: string | null;
|
threat_actor_name: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
|
start_date: string | null;
|
||||||
scheduled_at: string | null;
|
scheduled_at: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
target_platform: string | null;
|
target_platform: string | null;
|
||||||
@@ -55,6 +56,7 @@ export interface CampaignSummary {
|
|||||||
threat_actor_name: string | null;
|
threat_actor_name: string | null;
|
||||||
target_platform: string | null;
|
target_platform: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
start_date: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
test_count: number;
|
test_count: number;
|
||||||
completion_pct: number;
|
completion_pct: number;
|
||||||
@@ -63,6 +65,7 @@ export interface CampaignSummary {
|
|||||||
export interface CampaignCreatePayload {
|
export interface CampaignCreatePayload {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
start_date?: string; // ISO date YYYY-MM-DD — campaign won't activate before this
|
||||||
type?: string;
|
type?: string;
|
||||||
threat_actor_id?: string;
|
threat_actor_id?: string;
|
||||||
target_platform?: string;
|
target_platform?: string;
|
||||||
|
|||||||
@@ -254,6 +254,24 @@ export default function CampaignDetailPage() {
|
|||||||
<Calendar className="h-3.5 w-3.5" />
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
Created {formatDate(campaign.created_at)}
|
Created {formatDate(campaign.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
{campaign.start_date && (() => {
|
||||||
|
const sd = new Date(campaign.start_date);
|
||||||
|
const now = new Date();
|
||||||
|
const isPast = sd <= now;
|
||||||
|
const diffDays = Math.ceil((sd.getTime() - now.getTime()) / 86400000);
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
isPast
|
||||||
|
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||||
|
: "border-amber-500/30 bg-amber-500/10 text-amber-400"
|
||||||
|
}`}>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{isPast
|
||||||
|
? `Started ${formatDate(campaign.start_date)}`
|
||||||
|
: `Starts ${formatDate(campaign.start_date)} (${diffDays}d)`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{campaign.completed_at && (
|
{campaign.completed_at && (
|
||||||
<span className="flex items-center gap-1 text-green-400">
|
<span className="flex items-center gap-1 text-green-400">
|
||||||
<CheckCircle className="h-3.5 w-3.5" />
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default function CampaignsPage() {
|
|||||||
description: "",
|
description: "",
|
||||||
type: "custom",
|
type: "custom",
|
||||||
target_platform: "",
|
target_platform: "",
|
||||||
|
start_date: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
|
const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
|
||||||
@@ -73,7 +74,7 @@ export default function CampaignsPage() {
|
|||||||
onSuccess: (campaign) => {
|
onSuccess: (campaign) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
|
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "", start_date: "" });
|
||||||
navigate(`/campaigns/${campaign.id}`);
|
navigate(`/campaigns/${campaign.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -224,6 +225,29 @@ export default function CampaignsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Start date */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Start date
|
||||||
|
<span className="ml-2 text-xs font-normal text-gray-500">
|
||||||
|
(optional — campaign activates automatically on this date)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newCampaign.start_date}
|
||||||
|
min={new Date().toISOString().split("T")[0]}
|
||||||
|
onChange={(e) => setNewCampaign((c) => ({ ...c, start_date: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
{newCampaign.start_date && (
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-amber-400">
|
||||||
|
<span>⏰</span>
|
||||||
|
Tests won't be queued until {new Date(newCampaign.start_date + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}. Manual activation before that date is blocked.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user