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
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:
@@ -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] });
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user