feat(campaigns): campaign start date — scheduled activation, Jira start_date
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:
kitos
2026-06-03 16:57:06 +02:00
parent 3db9809be5
commit c62dafbc1f
10 changed files with 218 additions and 2 deletions

View File

@@ -31,6 +31,7 @@ export interface Campaign {
threat_actor_id: string | null;
threat_actor_name: string | null;
created_by: string | null;
start_date: string | null;
scheduled_at: string | null;
completed_at: string | null;
target_platform: string | null;
@@ -55,6 +56,7 @@ export interface CampaignSummary {
threat_actor_name: string | null;
target_platform: string | null;
tags: string[];
start_date: string | null;
created_at: string | null;
test_count: number;
completion_pct: number;
@@ -63,6 +65,7 @@ export interface CampaignSummary {
export interface CampaignCreatePayload {
name: string;
description?: string;
start_date?: string; // ISO date YYYY-MM-DD — campaign won't activate before this
type?: string;
threat_actor_id?: string;
target_platform?: string;

View File

@@ -254,6 +254,24 @@ export default function CampaignDetailPage() {
<Calendar className="h-3.5 w-3.5" />
Created {formatDate(campaign.created_at)}
</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 && (
<span className="flex items-center gap-1 text-green-400">
<CheckCircle className="h-3.5 w-3.5" />

View File

@@ -54,6 +54,7 @@ export default function CampaignsPage() {
description: "",
type: "custom",
target_platform: "",
start_date: "",
});
const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
@@ -73,7 +74,7 @@ export default function CampaignsPage() {
onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowCreateForm(false);
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "", start_date: "" });
navigate(`/campaigns/${campaign.id}`);
},
});
@@ -224,6 +225,29 @@ export default function CampaignsPage() {
</select>
</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 className="mt-6 flex justify-end gap-3">
<button