fix(campaigns): defer Jira ticket creation to start_date, gate recurring campaigns behind manager approval
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

- Approve endpoint now only creates Jira tickets immediately when
  start_date is now/past; a new periodic job (every 15 min) catches
  campaigns whose scheduled start_date has since arrived.
- Recurring campaign clones now go to pending_approval instead of
  active, routing through the same manager-approval gate as any other
  campaign; managers are notified instead of red_tech.
- Fix UTC conversion for the campaign approval start_date input and
  extract shared isoToDatetimeLocal/datetimeLocalToIso helpers.
This commit is contained in:
kitos
2026-07-16 11:09:10 +02:00
parent 0b60531677
commit cf4a6c3cde
9 changed files with 291 additions and 101 deletions
+26 -2
View File
@@ -27,7 +27,10 @@ from app.jobs.jira_sync_job import sync_all_jira_links
from app.jobs.retention_job import run_retention_job from app.jobs.retention_job import run_retention_job
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service # Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service import (
check_and_run_recurring_campaigns,
sync_due_campaign_jira_tickets,
)
# Import scan_intel from app.services.intel_service # Import scan_intel from app.services.intel_service
from app.services.intel_service import scan_intel from app.services.intel_service import scan_intel
@@ -164,6 +167,19 @@ def _run_recurring_campaigns() -> None:
db.close() db.close()
def _run_due_campaign_jira_sync() -> None:
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
logger.info("Scheduled due-campaign Jira sync starting...")
db = SessionLocal()
try:
processed = sync_due_campaign_jira_tickets(db)
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
except Exception:
logger.exception("Due-campaign Jira sync 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."""
# Log info: "Scheduled intel scan job starting..." # Log info: "Scheduled intel scan job starting..."
@@ -497,6 +513,14 @@ def start_scheduler() -> None:
# Keyword argument: replace_existing # Keyword argument: replace_existing
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
_run_due_campaign_jira_sync,
trigger="interval",
minutes=15,
id="due_campaign_jira_sync",
name="Due campaign Jira ticket sync (every 15 min)",
replace_existing=True,
)
# Call scheduler.add_job() # Call scheduler.add_job()
scheduler.add_job( scheduler.add_job(
sync_all_jira_links, sync_all_jira_links,
@@ -605,7 +629,7 @@ def start_scheduler() -> None:
# Literal argument value # Literal argument value
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), " "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
# Literal argument value # Literal argument value
"recurring_campaigns (daily), jira_sync (1h), " "recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), "
# Literal argument value # Literal argument value
"osint_enrichment (weekly), stale_detection (daily), " "osint_enrichment (weekly), stale_detection (daily), "
"retention_policies (daily), data_sources_sync (6h), " "retention_policies (daily), data_sources_sync (6h), "
+11 -29
View File
@@ -135,39 +135,21 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None: def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
"""Create Jira tickets for a campaign and its already-linked tests, if missing. """Create Jira tickets for *campaign* now, if its start_date has arrived.
Shared by both paths that bring a campaign to ``active``: the admin-only Shared by both paths that bring a campaign to ``active``: the admin-only
emergency `/activate` override and the normal manager `/approve` flow. emergency `/activate` override and the normal manager `/approve` flow.
Tests may already be linked to the campaign from when it was still a If ``start_date`` is still in the future, ticket creation is skipped
draft, i.e. before any Jira ticket existed for the campaign — so they here and left to the periodic ``sync_due_campaign_jira_tickets`` job,
need their own tickets created here too. Best-effort: failures are which creates them once that date actually arrives — this is what makes
logged, not raised, since a Jira outage must not block activation. the campaign's real scheduled date/time authoritative for when tickets
(and their Jira start-date field) appear, instead of always at approval
time.
""" """
try: if campaign.start_date and campaign.start_date > datetime.utcnow():
from app.services.jira_service import ( return
auto_create_campaign_issue, from app.services.jira_service import ensure_campaign_jira_tickets
auto_create_test_issue, ensure_campaign_jira_tickets(db, campaign, user)
get_campaign_jira_key,
get_test_jira_key,
)
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
if not campaign_jira_key:
campaign_jira_key = auto_create_campaign_issue(db, campaign, 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, user,
parent_ticket_override=campaign_jira_key,
campaign_start_date=campaign.start_date,
)
db.commit()
except Exception:
logger.exception(
"Jira ticket creation failed while activating campaign %s",
campaign_id,
)
# ── Pydantic schemas ───────────────────────────────────────────────── # ── Pydantic schemas ─────────────────────────────────────────────────
@@ -72,32 +72,24 @@ def _clone_campaign(db: Session, original: Campaign) -> Campaign:
1. Clone the campaign with a date-stamped name. 1. Clone the campaign with a date-stamped name.
2. For each ``CampaignTest`` in the original, create a new ``Test`` 2. For each ``CampaignTest`` in the original, create a new ``Test``
with the same base data (in ``draft`` state) and link it. with the same base data (in ``draft`` state) and link it.
3. Activate the new campaign. 3. Queue the new campaign for manager approval — recurrence only
automates *creating* the run, not skipping the same approval gate
every other campaign goes through. A manager still picks the real
start date (and that's what triggers Jira ticket creation) via the
normal /approve endpoint.
""" """
# Assign now = datetime.utcnow()
now = datetime.utcnow() now = datetime.utcnow()
# Assign run_label = now.strftime("%Y-%m-%d")
run_label = now.strftime("%Y-%m-%d") run_label = now.strftime("%Y-%m-%d")
# Assign child = Campaign(
child = Campaign( child = Campaign(
# Keyword argument: name
name=f"{original.name} (Run {run_label})", name=f"{original.name} (Run {run_label})",
# Keyword argument: description
description=original.description, description=original.description,
# Keyword argument: type
type=original.type, type=original.type,
# Keyword argument: threat_actor_id
threat_actor_id=original.threat_actor_id, threat_actor_id=original.threat_actor_id,
# Keyword argument: status status="pending_approval",
status="active",
# Keyword argument: created_by
created_by=original.created_by, created_by=original.created_by,
# Keyword argument: target_platform
target_platform=original.target_platform, target_platform=original.target_platform,
# Keyword argument: tags
tags=original.tags or [], tags=original.tags or [],
# Keyword argument: parent_campaign_id
parent_campaign_id=original.id, parent_campaign_id=original.id,
) )
# Stage new record(s) for database insertion # Stage new record(s) for database insertion
@@ -239,46 +231,34 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
# Commit all pending changes to the database # Commit all pending changes to the database
db.commit() db.commit()
# Notify # Notify the creator — the run happened, but it still needs a
# manager's approval before it goes anywhere.
if campaign.created_by: if campaign.created_by:
# Call create_notification()
create_notification( create_notification(
db, db,
# Keyword argument: user_id
user_id=campaign.created_by, user_id=campaign.created_by,
# Keyword argument: type
type="recurring_campaign_run", type="recurring_campaign_run",
# Keyword argument: title title="Recurring campaign created — awaiting approval",
title="Recurring campaign executed",
# Keyword argument: message
message=( message=(
f'Campaign "{child.name}" was automatically created ' f'Campaign "{child.name}" was automatically created '
f'from recurring template "{campaign.name}".' f'from recurring template "{campaign.name}" and is now '
f'queued for manager approval.'
), ),
# Keyword argument: entity_type
entity_type="campaign", entity_type="campaign",
# Keyword argument: entity_id
entity_id=child.id, entity_id=child.id,
) )
# Notify red_tech users # Notify managers — same approval gate as any other campaign,
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712 # recurrence only automates spawning the run, not skipping review.
# Iterate over red_techs managers = db.query(User).filter(User.role == "manager", User.is_active == True).all() # noqa: E712
for user in red_techs: for user in managers:
# Call create_notification()
create_notification( create_notification(
db, db,
# Keyword argument: user_id
user_id=user.id, user_id=user.id,
# Keyword argument: type type="campaign_pending_approval",
type="campaign_activated", title="Recurring campaign needs approval",
# Keyword argument: title message=f'Campaign "{child.name}" was auto-created from a recurring template and needs your approval.',
title="New recurring campaign active",
# Keyword argument: message
message=f'Campaign "{child.name}" is now active and ready for execution.',
# Keyword argument: entity_type
entity_type="campaign", entity_type="campaign",
# Keyword argument: entity_id
entity_id=child.id, entity_id=child.id,
) )
@@ -296,3 +276,61 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
# Return spawned # Return spawned
return spawned return spawned
# ---------------------------------------------------------------------------
# Catch up on due campaigns' Jira tickets (periodic job)
# ---------------------------------------------------------------------------
def sync_due_campaign_jira_tickets(db: Session) -> int:
"""Create Jira tickets for active campaigns whose start_date has arrived.
A campaign approved with a future ``start_date`` intentionally skips
Jira ticket creation at approval time (see
``app.routers.campaigns._create_jira_tickets_for_campaign``). This job
is what actually creates those tickets once that date arrives — it
finds active campaigns lacking a Jira link and, for any whose
start_date is now due, calls the same idempotent creation helper.
Returns the number of campaigns processed.
"""
from app.models.jira_link import JiraLink, JiraLinkEntityType
from app.services.jira_service import ensure_campaign_jira_tickets
now = datetime.utcnow()
linked_campaign_ids = {
row[0]
for row in db.query(JiraLink.entity_id).filter(
JiraLink.entity_type == JiraLinkEntityType.campaign
).all()
}
due_campaigns = (
db.query(Campaign)
.filter(
Campaign.status == "active",
Campaign.start_date.isnot(None),
Campaign.start_date <= now,
)
.all()
)
processed = 0
for campaign in due_campaigns:
if campaign.id in linked_campaign_ids:
continue
actor = db.query(User).filter(User.id == campaign.approved_by).first()
if not actor:
actor = db.query(User).filter(User.id == campaign.created_by).first()
if not actor:
logger.warning(
"Cannot sync Jira tickets for campaign %s: no valid actor found",
campaign.id,
)
continue
ensure_campaign_jira_tickets(db, campaign, actor)
processed += 1
return processed
+31
View File
@@ -705,6 +705,37 @@ def auto_create_campaign_issue(
return None return None
def ensure_campaign_jira_tickets(db: Session, campaign, user: User) -> None:
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
Called from two places: immediately at manager-approval time when the
campaign's ``start_date`` is now/past, and from the periodic
``sync_due_campaign_jira_tickets`` job for campaigns approved with a
future ``start_date`` once that date actually arrives. Idempotent —
checks for existing links before creating anything — so it is safe to
call repeatedly. Best-effort: failures are logged, not raised, since a
Jira outage must not block campaign activation.
"""
try:
campaign_jira_key = get_campaign_jira_key(db, campaign.id)
if not campaign_jira_key:
campaign_jira_key = auto_create_campaign_issue(db, campaign, 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, user,
parent_ticket_override=campaign_jira_key,
campaign_start_date=campaign.start_date,
)
db.commit()
except Exception:
logger.exception(
"Jira ticket creation failed for campaign %s",
campaign.id,
)
def auto_create_test_issue( def auto_create_test_issue(
db: Session, db: Session,
test: Test, test: Test,
+30 -4
View File
@@ -45,7 +45,7 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
"post", "post",
f"/api/v1/campaigns/{campaign.id}/approve", f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers, manager_headers,
json={"start_date": "2026-09-01T00:00:00"}, json={"start_date": "2020-01-01T00:00:00"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
@@ -53,10 +53,13 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
assert body["start_date"] is not None assert body["start_date"] is not None
def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers): def test_manager_approval_creates_jira_tickets_when_start_date_is_due(
api, db, red_lead_user, red_lead_headers, manager_headers
):
"""The normal manager-approval path must create Jira tickets too, not just """The normal manager-approval path must create Jira tickets too, not just
the admin-only emergency /activate override — this was the Block 1 gap: the admin-only emergency /activate override — this was the Block 1 gap:
campaigns approved through the standard flow never got a Jira ticket.""" campaigns approved through the standard flow never got a Jira ticket.
Uses a past start_date so ticket creation is due immediately."""
campaign = _make_draft_campaign(db, red_lead_user.id) campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
@@ -68,7 +71,7 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
"post", "post",
f"/api/v1/campaigns/{campaign.id}/approve", f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers, manager_headers,
json={"start_date": "2026-09-01T00:00:00"}, json={"start_date": "2020-01-01T00:00:00"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -77,6 +80,29 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
mock_create_test.assert_called_once() mock_create_test.assert_called_once()
def test_manager_approval_skips_jira_tickets_when_start_date_is_future(
api, db, red_lead_user, red_lead_headers, manager_headers
):
"""A campaign scheduled to start in the future must not get its Jira
tickets created at approval time — that must wait for the periodic
due-campaign sync job, once the scheduled start_date actually arrives."""
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign:
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers,
json={"start_date": "2099-01-01T00:00:00"},
)
assert resp.status_code == 200
mock_get_key.assert_not_called()
mock_create_campaign.assert_not_called()
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers): def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id) campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers) api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
+85 -1
View File
@@ -21,6 +21,7 @@ from app.services.campaign_service import (
from app.services.campaign_scheduler_service import ( from app.services.campaign_scheduler_service import (
calculate_next_run, calculate_next_run,
check_and_run_recurring_campaigns, check_and_run_recurring_campaigns,
sync_due_campaign_jira_tickets,
) )
from app.services.snapshot_service import ( from app.services.snapshot_service import (
create_snapshot, create_snapshot,
@@ -199,7 +200,10 @@ class TestCampaigns:
) )
assert child is not None assert child is not None
assert "Run" in child.name assert "Run" in child.name
assert child.status == "active" # Recurrence only automates spawning the run — it still needs a
# manager's approval like any other campaign, same as the manual
# draft -> submit -> approve path.
assert child.status == "pending_approval"
# Check child tests are fresh copies (new IDs, draft state) # Check child tests are fresh copies (new IDs, draft state)
child_cts = ( child_cts = (
@@ -217,12 +221,92 @@ class TestCampaigns:
test = db.query(Test).filter(Test.id == ct.test_id).first() test = db.query(Test).filter(Test.id == ct.test_id).first()
assert test.state == TestState.draft assert test.state == TestState.draft
def test_campaign_cloning_notifies_managers(self, db, campaign_with_tests, manager_user):
"""Managers must hear about a spawned recurring run — it's sitting in
their approval queue just like a manually-submitted campaign."""
from app.models.notification import Notification
campaign = campaign_with_tests["campaign"]
campaign.is_recurring = True
campaign.recurrence_pattern = "monthly"
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
check_and_run_recurring_campaigns(db)
notif = (
db.query(Notification)
.filter(Notification.user_id == manager_user.id, Notification.type == "campaign_pending_approval")
.first()
)
assert notif is not None
# Check parent was updated # Check parent was updated
db.refresh(campaign) db.refresh(campaign)
assert campaign.last_run_at is not None assert campaign.last_run_at is not None
assert campaign.next_run_at > datetime.utcnow() assert campaign.next_run_at > datetime.utcnow()
class TestDueCampaignJiraSync:
"""sync_due_campaign_jira_tickets — the periodic catch-up job for
campaigns approved with a future start_date whose Jira tickets were
deliberately skipped at approval time."""
def test_creates_tickets_for_due_campaign_without_jira_link(self, db, campaign_with_tests, admin_user):
from unittest.mock import patch
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() - timedelta(hours=1) # now due
db.commit()
with patch(
"app.services.jira_service.get_campaign_jira_key", return_value=None
) as mock_get_key, patch(
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-200"
) as mock_create_campaign, patch(
"app.services.jira_service.get_test_jira_key", return_value=None
), patch(
"app.services.jira_service.auto_create_test_issue"
) as mock_create_test:
processed = sync_due_campaign_jira_tickets(db)
assert processed == 1
mock_get_key.assert_called_once()
mock_create_campaign.assert_called_once()
assert mock_create_test.call_count == len(campaign_with_tests["tests"])
def test_skips_campaign_with_future_start_date(self, db, campaign_with_tests, admin_user):
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() + timedelta(days=30)
db.commit()
processed = sync_due_campaign_jira_tickets(db)
assert processed == 0
def test_skips_campaign_already_linked_to_jira(self, db, campaign_with_tests, admin_user):
from app.models.jira_link import JiraLink, JiraLinkEntityType
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() - timedelta(hours=1)
db.add(JiraLink(
entity_type=JiraLinkEntityType.campaign,
entity_id=campaign.id,
jira_issue_key="PT-1",
jira_project_key="PT",
created_by=admin_user.id,
))
db.commit()
processed = sync_due_campaign_jira_tickets(db)
assert processed == 0
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# Snapshot Tests # Snapshot Tests
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
+7 -1
View File
@@ -41,6 +41,7 @@ import JiraLinkPanel from "../components/JiraLinkPanel";
import CampaignTimingPanel from "../components/CampaignTimingPanel"; import CampaignTimingPanel from "../components/CampaignTimingPanel";
import AddTestToCampaignModal from "../components/AddTestToCampaignModal"; import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal"; import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal";
import { datetimeLocalToIso } from "../utils/datetime";
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
@@ -115,7 +116,12 @@ export default function CampaignDetailPage() {
}); });
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: () => approveCampaign(campaignId!, approveStartDate), // approveStartDate comes from <input type="datetime-local"> — wall-clock
// time in the browser's own timezone. Sending it raw let the backend
// (which treats it as UTC) silently shift the campaign's real start
// time by the browser's UTC offset, causing Jira tickets to be created
// before the date/time the manager actually picked.
mutationFn: () => approveCampaign(campaignId!, datetimeLocalToIso(approveStartDate)!),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] }); queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
+1 -26
View File
@@ -43,32 +43,7 @@ import ConfirmDialog from "../components/ConfirmDialog";
import JiraLinkPanel from "../components/JiraLinkPanel"; import JiraLinkPanel from "../components/JiraLinkPanel";
import TestPhaseTimeline from "../components/TestPhaseTimeline"; import TestPhaseTimeline from "../components/TestPhaseTimeline";
import { createTemplate } from "../api/test-templates"; import { createTemplate } from "../api/test-templates";
import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime";
// ── Helpers ────────────────────────────────────────────────────────
// The backend always stores/returns naive UTC datetimes (no "Z", no
// offset). <input type="datetime-local"> shows/edits wall-clock time in
// the browser's own timezone, so both directions need an explicit
// UTC <-> local conversion — treating the raw digits as interchangeable
// silently shifts every value by the browser's UTC offset.
function isoToDatetimeLocal(iso: string | null): string {
if (!iso) return "";
const utcStr = iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z";
const d = new Date(utcStr);
if (isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function datetimeLocalToIso(local: string): string | undefined {
if (!local) return undefined;
// New Date() on a plain "YYYY-MM-DDTHH:mm" string (no zone designator)
// parses it as local time per the ECMA-262 date string spec — exactly
// the wall-clock time the user picked.
const d = new Date(local);
if (isNaN(d.getTime())) return undefined;
return d.toISOString();
}
// ── Page Component ───────────────────────────────────────────────── // ── Page Component ─────────────────────────────────────────────────
+24
View File
@@ -0,0 +1,24 @@
// The backend always stores/returns naive UTC datetimes (no "Z", no
// offset). <input type="datetime-local"> shows/edits wall-clock time in
// the browser's own timezone, so both directions need an explicit
// UTC <-> local conversion — treating the raw digits as interchangeable
// silently shifts every value by the browser's UTC offset.
export function isoToDatetimeLocal(iso: string | null): string {
if (!iso) return "";
const utcStr = iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z";
const d = new Date(utcStr);
if (isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function datetimeLocalToIso(local: string): string | undefined {
if (!local) return undefined;
// New Date() on a plain "YYYY-MM-DDTHH:mm" string (no zone designator)
// parses it as local time per the ECMA-262 date string spec — exactly
// the wall-clock time the user picked.
const d = new Date(local);
if (isNaN(d.getTime())) return undefined;
return d.toISOString();
}