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
+7 -1
View File
@@ -41,6 +41,7 @@ import JiraLinkPanel from "../components/JiraLinkPanel";
import CampaignTimingPanel from "../components/CampaignTimingPanel";
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal";
import { datetimeLocalToIso } from "../utils/datetime";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
@@ -115,7 +116,12 @@ export default function CampaignDetailPage() {
});
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: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", 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 TestPhaseTimeline from "../components/TestPhaseTimeline";
import { createTemplate } from "../api/test-templates";
// ── 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();
}
import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime";
// ── 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();
}