- {navSections.map((section) => (
-
-
-
{section.icon}
- {section.label}
+ {navSections.map((section) => {
+ const isExpanded = expandedSections[section.label] ?? false;
+ return (
+
+
+ section.collapsible && toggleSection(section.label)
+ }
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '10px',
+ padding: '10px 20px',
+ fontSize: '14px',
+ fontWeight: 600,
+ color: '#1f2937',
+ cursor: section.collapsible ? 'pointer' : 'default',
+ userSelect: 'none',
+ }}
+ >
+ {section.icon}
+ {section.label}
+ {section.collapsible && (
+
+ ▾
+
+ )}
+
+ {isExpanded &&
+ section.items.map((item) => {
+ const isActive =
+ item.path === '/campaigns'
+ ? location.pathname === '/campaigns'
+ : location.pathname.startsWith(item.path);
+ return (
+
+ {item.label}
+
+ );
+ })}
- {section.items.map((item) => {
- const isActive =
- item.path === '/campaigns'
- ? location.pathname === '/campaigns'
- : location.pathname.startsWith(item.path);
- return (
-
-
{item.icon}
- {item.label}
-
- );
- })}
-
- ))}
+ );
+ })}
+
+ {/* Bottom entity info */}
+
+
+ 👤
+
+
+
+ Entity Name
+
+
+ Entity Info
+
+
+
⬦
+
{/* Main Content */}
@@ -192,20 +374,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
}}
>
{'\u{1F3E0}'}{' '}
-
- Marketing >{' '}
- {location.pathname === '/dashboard'
- ? 'Dashboard'
- : location.pathname === '/campaigns/new'
- ? 'Create campaign'
- : location.pathname.includes('/analytics')
- ? 'Analytics'
- : location.pathname.includes('/edit')
- ? 'Edit campaign'
- : location.pathname.startsWith('/campaigns/')
- ? 'Campaign detail'
- : 'Campaigns'}
-
+
Marketing > {getBreadcrumb()}
{children}
diff --git a/campaign-admin/src/pages/CampaignWizardPage.tsx b/campaign-admin/src/pages/CampaignWizardPage.tsx
new file mode 100644
index 000000000..c05cc757e
--- /dev/null
+++ b/campaign-admin/src/pages/CampaignWizardPage.tsx
@@ -0,0 +1,1038 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { createCampaign } from '../api/campaigns';
+import type { CampaignFormData } from '../types/campaign';
+
+/* ─── Types ─── */
+interface Asset {
+ id: number;
+ type: 'image' | 'text' | 'html';
+ imageTag: string;
+ imageAlt: string;
+ ctaLink: string;
+ textContent: string;
+ htmlContent: string;
+ fileName: string;
+}
+
+interface LocationSection {
+ name: string;
+ expanded: boolean;
+ website: { topBanner: boolean; bottomBanner: boolean; rightColTop: boolean; rightColBottom: boolean; interstitial: boolean };
+ mobile: { topBanner: boolean; bottomBanner: boolean; interstitial: boolean };
+}
+
+interface UserSegment {
+ id: string;
+ name: string;
+ totalUsers: number;
+}
+
+/* ─── Sample Data ─── */
+const SAMPLE_SEGMENTS: UserSegment[] = [
+ { id: 's1', name: 'High balance checking', totalUsers: 12678 },
+ { id: 's2', name: 'Young professional in CA', totalUsers: 12678 },
+ { id: 's3', name: 'Digital users only', totalUsers: 12678 },
+ { id: 's4', name: 'Banksegment', totalUsers: 12678 },
+ { id: 's5', name: 'BU4CIBC bank', totalUsers: 12678 },
+ { id: 's6', name: 'Business', totalUsers: 12678 },
+ { id: 's7', name: 'Business123', totalUsers: 12678 },
+ { id: 's8', name: 'Business@Jan1', totalUsers: 12678 },
+ { id: 's9', name: 'Commercial', totalUsers: 12678 },
+ { id: 's10', name: 'Commercial22/12', totalUsers: 12678 },
+ { id: 's11', name: 'Credit Union', totalUsers: 12678 },
+ { id: 's12', name: 'CU@Nov12', totalUsers: 12678 },
+ { id: 's13', name: 'Long name segment', totalUsers: 12678 },
+ { id: 's14', name: 'MyNewSegmentforC...', totalUsers: 12678 },
+ { id: 's15', name: 'NewSegment', totalUsers: 12678 },
+ { id: 's16', name: 'PNC', totalUsers: 12678 },
+ { id: 's17', name: 'PNC@Business', totalUsers: 12678 },
+ { id: 's18', name: 'Retail', totalUsers: 12678 },
+ { id: 's19', name: 'Retail$123', totalUsers: 12678 },
+ { id: 's20', name: 'Retail456', totalUsers: 12678 },
+ { id: 's21', name: 'SegmentforBank', totalUsers: 12678 },
+ { id: 's22', name: 'SegmentNewNew', totalUsers: 12678 },
+ { id: 's23', name: 'Short name', totalUsers: 12678 },
+ { id: 's24', name: 'Premium Members', totalUsers: 8432 },
+];
+
+const PRODUCT_CATEGORIES = [
+ 'Savings', 'Checking', 'Credit Card', 'Loans', 'Mortgage',
+ 'Investment', 'Insurance', 'Wealth Management',
+];
+
+const PRIORITY_OPTIONS = ['Low', 'Medium', 'High', 'Critical'];
+
+const STEPS = ['Setup', 'Content', 'Segment', 'Location', 'Review'] as const;
+
+const DEFAULT_LOCATIONS: LocationSection[] = [
+ {
+ name: 'Account summary',
+ expanded: true,
+ website: { topBanner: false, bottomBanner: false, rightColTop: false, rightColBottom: false, interstitial: false },
+ mobile: { topBanner: false, bottomBanner: false, interstitial: false },
+ },
+ {
+ name: 'Make a transfer',
+ expanded: false,
+ website: { topBanner: false, bottomBanner: false, rightColTop: false, rightColBottom: false, interstitial: false },
+ mobile: { topBanner: false, bottomBanner: false, interstitial: false },
+ },
+ {
+ name: 'Payments',
+ expanded: false,
+ website: { topBanner: false, bottomBanner: false, rightColTop: false, rightColBottom: false, interstitial: false },
+ mobile: { topBanner: false, bottomBanner: false, interstitial: false },
+ },
+ {
+ name: 'Bill pay',
+ expanded: false,
+ website: { topBanner: false, bottomBanner: false, rightColTop: false, rightColBottom: false, interstitial: false },
+ mobile: { topBanner: false, bottomBanner: false, interstitial: false },
+ },
+];
+
+/* ─── Channel config ─── */
+const CHANNELS = [
+ { key: 'IN_APP', label: 'In-app', icon: '📱' },
+ { key: 'EMAIL', label: 'Email', icon: '✉️' },
+ { key: 'SMS', label: 'SMS', icon: '💬' },
+ { key: 'SOCIAL', label: 'Social media', icon: '📣' },
+ { key: 'ADS', label: 'Ads', icon: '💎' },
+] as const;
+
+export function CampaignWizardPage() {
+ const navigate = useNavigate();
+ const [currentStep, setCurrentStep] = useState(0);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+
+ /* Step 1 - Setup state */
+ const [campaignName, setCampaignName] = useState('');
+ const [description, setDescription] = useState('');
+ const [keywords, setKeywords] = useState('');
+ const [productCategory, setProductCategory] = useState('');
+ const [priority, setPriority] = useState('');
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [selectedChannels, setSelectedChannels] = useState
(['IN_APP']);
+
+ /* Step 2 - Content state */
+ const [assets, setAssets] = useState([
+ { id: 1, type: 'image', imageTag: '', imageAlt: '', ctaLink: '', textContent: '', htmlContent: '', fileName: '' },
+ ]);
+
+ /* Step 3 - Segment state */
+ const [selectedSegments, setSelectedSegments] = useState([]);
+ const [showSegmentModal, setShowSegmentModal] = useState(false);
+ const [segmentSearch, setSegmentSearch] = useState('');
+ const [tempSelectedSegments, setTempSelectedSegments] = useState([]);
+
+ /* Step 4 - Location state */
+ const [locations, setLocations] = useState(DEFAULT_LOCATIONS);
+
+ const canGoNext = (): boolean => {
+ if (currentStep === 0) return campaignName.trim().length > 0;
+ return true;
+ };
+
+ const handleNext = () => {
+ if (currentStep < STEPS.length - 1) setCurrentStep(currentStep + 1);
+ };
+
+ const handleBack = () => {
+ if (currentStep > 0) setCurrentStep(currentStep - 1);
+ };
+
+ const goToStep = (step: number) => {
+ setCurrentStep(step);
+ };
+
+ const handleSubmit = async () => {
+ setError('');
+ setSaving(true);
+ try {
+ const formData: CampaignFormData = {
+ name: campaignName,
+ targetAudienceSegment: selectedSegments.map(s => s.name).join(', '),
+ startDate,
+ endDate,
+ messageTitle: assets.find(a => a.type === 'text')?.textContent.slice(0, 100) || campaignName,
+ messageBody: assets.find(a => a.type === 'text')?.textContent || '',
+ messageImageUrl: assets.find(a => a.type === 'image')?.fileName || '',
+ messageCtaText: assets.find(a => a.type === 'image')?.ctaLink || '',
+ fulfillmentActionType: 'ACCEPT',
+ displayPlacement: 'POST_LOGIN',
+ frequencyCapType: 'ONCE_PER_CAMPAIGN',
+ frequencyCapMaxImpressions: 1,
+ deliveryStartTime: '',
+ deliveryEndTime: '',
+ personalizationTokens: '',
+ remindLaterDeferralDays: 1,
+ fulfillmentWorkflowUrl: '',
+ declineSuppression: true,
+ confirmationMessage: 'Thank you for your response!',
+ audienceRules: '',
+ channel: selectedChannels[0] || 'IN_APP',
+ priority: PRIORITY_OPTIONS.indexOf(priority) + 1 || 5,
+ tags: keywords,
+ abTestEnabled: false,
+ };
+ await createCampaign(formData);
+ navigate('/campaigns');
+ } catch {
+ setError('Failed to create campaign. Please check your inputs.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ /* ─── Asset helpers ─── */
+ const addAsset = () => {
+ setAssets(prev => [
+ ...prev,
+ { id: Date.now(), type: 'image', imageTag: '', imageAlt: '', ctaLink: '', textContent: '', htmlContent: '', fileName: '' },
+ ]);
+ };
+
+ const removeAsset = (id: number) => {
+ if (assets.length > 1) setAssets(prev => prev.filter(a => a.id !== id));
+ };
+
+ const updateAsset = (id: number, field: keyof Asset, value: string) => {
+ setAssets(prev => prev.map(a => a.id === id ? { ...a, [field]: value } : a));
+ };
+
+ /* ─── Channel toggle ─── */
+ const toggleChannel = (key: string) => {
+ setSelectedChannels(prev =>
+ prev.includes(key) ? prev.filter(c => c !== key) : [...prev, key]
+ );
+ };
+
+ /* ─── Location helpers ─── */
+ const toggleLocationExpand = (idx: number) => {
+ setLocations(prev => prev.map((l, i) => i === idx ? { ...l, expanded: !l.expanded } : l));
+ };
+
+ const toggleLocationCheckbox = (
+ idx: number,
+ platform: 'website' | 'mobile',
+ field: string,
+ ) => {
+ setLocations(prev => prev.map((l, i) => {
+ if (i !== idx) return l;
+ return {
+ ...l,
+ [platform]: { ...l[platform], [field]: !(l[platform] as Record)[field] },
+ };
+ }));
+ };
+
+ /* ─── Segment modal helpers ─── */
+ const openSegmentModal = () => {
+ setTempSelectedSegments([...selectedSegments]);
+ setSegmentSearch('');
+ setShowSegmentModal(true);
+ };
+
+ const toggleTempSegment = (seg: UserSegment) => {
+ setTempSelectedSegments(prev =>
+ prev.find(s => s.id === seg.id)
+ ? prev.filter(s => s.id !== seg.id)
+ : [...prev, seg]
+ );
+ };
+
+ const confirmSegments = () => {
+ setSelectedSegments(tempSelectedSegments);
+ setShowSegmentModal(false);
+ };
+
+ const removeSegment = (id: string) => {
+ setSelectedSegments(prev => prev.filter(s => s.id !== id));
+ };
+
+ const filteredSegments = SAMPLE_SEGMENTS.filter(s =>
+ s.name.toLowerCase().includes(segmentSearch.toLowerCase())
+ );
+
+ const estimatedReach = selectedSegments.reduce((sum, s) => sum + s.totalUsers, 0);
+
+ /* ─── Location summary for review ─── */
+ const getLocationSummary = () => {
+ const webLocs: string[] = [];
+ const mobileLocs: string[] = [];
+ locations.forEach(loc => {
+ const wFields = loc.website;
+ if (wFields.topBanner) webLocs.push(`${loc.name}-top banner`);
+ if (wFields.bottomBanner) webLocs.push(`${loc.name}-bottom banner`);
+ if (wFields.rightColTop) webLocs.push(`${loc.name}-right column top`);
+ if (wFields.rightColBottom) webLocs.push(`${loc.name}-right column bottom`);
+ if (wFields.interstitial) webLocs.push(`${loc.name}-interstitial`);
+ const mFields = loc.mobile;
+ if (mFields.topBanner) mobileLocs.push(`${loc.name}-top banner`);
+ if (mFields.bottomBanner) mobileLocs.push(`${loc.name}-bottom banner`);
+ if (mFields.interstitial) mobileLocs.push(`${loc.name}-interstitial`);
+ });
+ return { webLocs, mobileLocs };
+ };
+
+ return (
+
+ {/* Step indicator */}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Step content */}
+
+ {currentStep === 0 && (
+
+ )}
+ {currentStep === 1 && (
+
+ )}
+ {currentStep === 2 && (
+
+ )}
+ {currentStep === 3 && (
+
+ )}
+ {currentStep === 4 && (
+
+ )}
+
+
+ {/* Navigation buttons */}
+
+ {currentStep > 0 && (
+
+ )}
+ {currentStep < STEPS.length - 1 && (
+
+ )}
+ {currentStep === STEPS.length - 1 && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Segment Modal */}
+ {showSegmentModal && (
+
setShowSegmentModal(false)}
+ />
+ )}
+
+ );
+}
+
+/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ Step Indicator
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
+function StepIndicator({ currentStep, onStepClick }: { currentStep: number; onStepClick: (step: number) => void }) {
+ return (
+
+ {STEPS.map((step, idx) => {
+ const isCompleted = idx < currentStep;
+ const isCurrent = idx === currentStep;
+ return (
+
+ {idx > 0 && (
+
+ )}
+ onStepClick(idx)}
+ style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', minWidth: '80px' }}
+ >
+
+ {isCompleted ? '✓' : idx + 1}
+
+
+ {step}
+
+
+
+ );
+ })}
+
+ );
+}
+
+/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ Step 1 – Setup
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
+function StepSetup({
+ campaignName, setCampaignName, description, setDescription,
+ keywords, setKeywords, productCategory, setProductCategory,
+ priority, setPriority, startDate, setStartDate, endDate, setEndDate,
+ selectedChannels, toggleChannel,
+}: {
+ campaignName: string; setCampaignName: (v: string) => void;
+ description: string; setDescription: (v: string) => void;
+ keywords: string; setKeywords: (v: string) => void;
+ productCategory: string; setProductCategory: (v: string) => void;
+ priority: string; setPriority: (v: string) => void;
+ startDate: string; setStartDate: (v: string) => void;
+ endDate: string; setEndDate: (v: string) => void;
+ selectedChannels: string[]; toggleChannel: (k: string) => void;
+}) {
+ return (
+ <>
+ Campaign details
+
+
+ setCampaignName(e.target.value)}
+ style={inputStyle} placeholder="Campaign name" required
+ />
+
+
+
+
+
+
+ setKeywords(e.target.value)}
+ style={inputStyle} placeholder="Keywords (separated by comma)"
+ />
+ Optional
+
+
+
+
+
+ Optional
+
+
+
+ Optional
+
+
+
+
+
+ Channel
+
+ {CHANNELS.map(ch => {
+ const selected = selectedChannels.includes(ch.key);
+ return (
+
toggleChannel(ch.key)}
+ style={{
+ width: '120px', padding: '20px 16px', borderRadius: '8px', cursor: 'pointer',
+ border: selected ? '2px solid #4ade80' : '2px solid #e5e7eb',
+ background: selected ? '#f0fdf4' : '#fff',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px',
+ position: 'relative', transition: 'all 0.15s',
+ }}
+ >
+ {selected && (
+
✓
+ )}
+ {!selected && (
+
+ )}
+
{ch.icon}
+
{ch.label}
+
+ );
+ })}
+
+ >
+ );
+}
+
+/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ Step 2 – Content
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
+function StepContent({
+ assets, addAsset, removeAsset, updateAsset,
+}: {
+ assets: Asset[];
+ addAsset: () => void;
+ removeAsset: (id: number) => void;
+ updateAsset: (id: number, field: keyof Asset, value: string) => void;
+}) {
+ return (
+ <>
+
+
+
+
+
+ {assets.map((asset, idx) => (
+
+
+
Asset {idx + 1}
+ {assets.length > 1 && (
+
+ )}
+
+
+ {/* Type selector */}
+
+ {(['image', 'text', 'html'] as const).map(t => (
+
+ ))}
+
+
+ {/* Image type */}
+ {asset.type === 'image' && (
+ <>
+
+ ℹ️ Please upload WebP, JPG, or PNG files. Max file size: 2MB.
+
+
+
+ Drop your file here
+
+
+ updateAsset(asset.id, 'imageTag', e.target.value)} style={inputStyle} placeholder="Image tag option" />
+ updateAsset(asset.id, 'imageAlt', e.target.value)} style={inputStyle} placeholder="Image alt text" />
+
+
+ updateAsset(asset.id, 'ctaLink', e.target.value)} style={inputStyle} placeholder="CTA link" />
+ Optional
+
+ >
+ )}
+
+ {/* Text type */}
+ {asset.type === 'text' && (
+ <>
+
+
+ {['B', 'I', 'U', '⌐', '≡', '⊕', '⊗', 'A', 'Aa', '⊞', '—'].map((btn, i) => (
+
+ ))}
+ {['≡₁', '≡₂', '≡₃', '☰', '☰₂', '☰₃'].map((btn, i) => (
+
+ ))}
+
+
+ ))}
+
+
+ >
+ );
+}
+
+/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ Step 3 – Segment
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
+function StepSegment({
+ selectedSegments, openSegmentModal, removeSegment, estimatedReach,
+}: {
+ selectedSegments: UserSegment[];
+ openSegmentModal: () => void;
+ removeSegment: (id: string) => void;
+ estimatedReach: number;
+}) {
+ return (
+ <>
+ Segment
+
+
+
+
+
+ {selectedSegments.length > 0 && (
+ <>
+
+
+
+ {selectedSegments.map(seg => (
+
+ {seg.name}
+
+
+ ))}
+
+
+
+
+
👥
+
+
Estimated reach
+
{estimatedReach.toLocaleString()}
+
+
+ >
+ )}
+
+ {selectedSegments.length === 0 && (
+
+
👥
+
No segments selected yet
+
Click the button above to select user segments for this campaign
+
+ )}
+ >
+ );
+}
+
+/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ Step 4 – Location
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
+function StepLocation({
+ locations, toggleLocationExpand, toggleLocationCheckbox,
+}: {
+ locations: LocationSection[];
+ toggleLocationExpand: (idx: number) => void;
+ toggleLocationCheckbox: (idx: number, platform: 'website' | 'mobile', field: string) => void;
+}) {
+ return (
+ <>
+ Campaign location
+ {locations.map((loc, idx) => (
+
+
toggleLocationExpand(idx)}
+ style={{
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
+ padding: '16px 20px', cursor: 'pointer', background: '#fafafa',
+ borderBottom: loc.expanded ? '1px solid #e5e7eb' : 'none',
+ }}
+ >
+ {loc.name}
+
+ ▾
+
+
+ {loc.expanded && (
+
+ {/* Website */}
+
Website
+
+ {[
+ { key: 'topBanner', label: 'Top banner' },
+ { key: 'bottomBanner', label: 'Bottom banner' },
+ { key: 'rightColTop', label: 'Right column top' },
+ { key: 'rightColBottom', label: 'Right column bottom' },
+ { key: 'interstitial', label: 'Interstitial' },
+ ].map(item => (
+
+ ))}
+
+
+ {/* Mobile */}
+
Mobile
+
+ {[
+ { key: 'topBanner', label: 'Top banner' },
+ { key: 'bottomBanner', label: 'Bottom banner' },
+ { key: 'interstitial', label: 'Interstitial' },
+ ].map(item => (
+
+ ))}
+
+
+ )}
+
+ ))}
+ >
+ );
+}
+
+/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ Step 5 – Review
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
+function StepReview({
+ campaignName, productCategory, priority, startDate, endDate,
+ selectedChannels, assets, selectedSegments, estimatedReach,
+ locationSummary, goToStep,
+}: {
+ campaignName: string; productCategory: string; priority: string;
+ startDate: string; endDate: string; selectedChannels: string[];
+ assets: Asset[]; selectedSegments: UserSegment[]; estimatedReach: number;
+ locationSummary: { webLocs: string[]; mobileLocs: string[] };
+ goToStep: (step: number) => void;
+}) {
+ const channelLabels = selectedChannels.map(k => CHANNELS.find(c => c.key === k)?.label || k).join(', ');
+
+ return (
+ <>
+
+
Review
+
+
+
+ {/* Campaign details */}
+ goToStep(0)}>
+
+
+
+
+
+
+
+
+ {/* Content */}
+ goToStep(1)}>
+ {assets.map((asset, idx) => {
+ if (asset.type === 'image') {
+ return ;
+ }
+ if (asset.type === 'text') {
+ return ;
+ }
+ return ;
+ })}
+
+
+ {/* Segment */}
+ goToStep(2)}>
+ s.name).join('\n') || 'None'} />
+
+
+
+ {/* Location */}
+ goToStep(3)}>
+
+
+
+ >
+ );
+}
+
+function ReviewSection({ title, onEdit, children }: { title: string; onEdit: () => void; children: React.ReactNode }) {
+ return (
+
+
+
{title}
+
+
+ {children}
+
+ );
+}
+
+function ReviewRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ Segment Modal
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
+function SegmentModal({
+ segments, tempSelected, toggleTempSegment,
+ segmentSearch, setSegmentSearch, onConfirm, onCancel,
+}: {
+ segments: UserSegment[];
+ tempSelected: UserSegment[];
+ toggleTempSegment: (seg: UserSegment) => void;
+ segmentSearch: string;
+ setSegmentSearch: (v: string) => void;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+
+
+ {/* Header */}
+
+
Select user segment
+
+
+
+ {/* Search */}
+
+ setSegmentSearch(e.target.value)}
+ style={{ ...inputStyle, flex: 1 }} placeholder="Search by description"
+ />
+
+
+
+ {/* Selected chips */}
+ {tempSelected.length > 0 && (
+
+
+ User segment selected:
+
+ {tempSelected.map(seg => (
+
+ {seg.name}
+
+
+ ))}
+
+ )}
+
+ {/* Grid */}
+
+
+ {segments.map(seg => {
+ const checked = tempSelected.some(s => s.id === seg.id);
+ return (
+
+ );
+ })}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ );
+}
+
+/* ─── Shared Styles ─── */
+const stepTitle: React.CSSProperties = {
+ fontSize: '22px', fontWeight: 600, marginBottom: '24px', color: '#1a2744',
+};
+
+const fieldGroup: React.CSSProperties = { marginBottom: '16px' };
+
+const labelStyle: React.CSSProperties = {
+ display: 'block', fontSize: '14px', fontWeight: 500, color: '#374151', marginBottom: '6px',
+};
+
+const inputStyle: React.CSSProperties = {
+ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db',
+ borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' as const,
+};
+
+const optionalLabel: React.CSSProperties = {
+ fontSize: '11px', color: '#9ca3af', marginTop: '2px', display: 'block',
+};
+
+const navBtnPrimary: React.CSSProperties = {
+ padding: '10px 24px', border: 'none', borderRadius: '6px',
+ background: '#4a7c3f', color: '#fff', cursor: 'pointer',
+ fontSize: '14px', fontWeight: 600,
+};
+
+const navBtnOutline: React.CSSProperties = {
+ padding: '10px 24px', border: '1px solid #d1d5db', borderRadius: '6px',
+ background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151',
+};
diff --git a/campaign-admin/src/pages/PlaceholderPages.tsx b/campaign-admin/src/pages/PlaceholderPages.tsx
new file mode 100644
index 000000000..7acad4bdf
--- /dev/null
+++ b/campaign-admin/src/pages/PlaceholderPages.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+
+function PlaceholderPage({ title, icon, description }: { title: string; icon: string; description: string }) {
+ return (
+
+
{title}
+
+
{icon}
+
{description}
+
+
+ );
+}
+
+export function CampaignJourneyPage() {
+ return ;
+}
+
+export function LocationsPage() {
+ return ;
+}
+
+export function UserSegmentPage() {
+ return ;
+}
+
+export function SegmentCriteriaPage() {
+ return ;
+}
+
+export function McmMediaPage() {
+ return ;
+}
+
+export function InternalPreviewPage() {
+ return ;
+}
From aabc8f6e79e30ab404d3e14f71d80a73ef27e597 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 04:21:42 +0000
Subject: [PATCH 45/54] fix: correct priority mapping and add missing channel
labels
- Map wizard priority labels to proper 1-10 scale (Low=2, Medium=5, High=8, Critical=10) matching existing seed data
- Add SOCIAL and ADS to calendar page channel labels with fallback for unknown channels
---
campaign-admin/src/pages/CampaignCalendarPage.tsx | 4 +++-
campaign-admin/src/pages/CampaignWizardPage.tsx | 2 +-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/campaign-admin/src/pages/CampaignCalendarPage.tsx b/campaign-admin/src/pages/CampaignCalendarPage.tsx
index 99f68461e..e491a5525 100644
--- a/campaign-admin/src/pages/CampaignCalendarPage.tsx
+++ b/campaign-admin/src/pages/CampaignCalendarPage.tsx
@@ -9,6 +9,8 @@ const channelLabels: Record = {
EMAIL: 'Email',
SMS: 'SMS',
PUSH: 'Push',
+ SOCIAL: 'Social',
+ ADS: 'Ads',
};
const statusColors: Record = {
@@ -377,7 +379,7 @@ export function CampaignCalendarPage() {
fontWeight: 500,
}}
>
- {channelLabels[c.channel || 'IN_APP']}
+ {channelLabels[c.channel || 'IN_APP'] || c.channel || 'IN_APP'}
)[priority] ?? 5 : 5,
tags: keywords,
abTestEnabled: false,
};
From 7189cbca503b9a3f6c463eca8d310d4e5345de06 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 04:32:03 +0000
Subject: [PATCH 46/54] fix: wrap synchronous SQLite calls in asyncio.to_thread
in async handlers
All async endpoint handlers now use await asyncio.to_thread() for DB calls,
matching the pattern already used in _run_research background task.
---
intel-agent/backend/app/main.py | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/intel-agent/backend/app/main.py b/intel-agent/backend/app/main.py
index d13d92694..e81597e96 100644
--- a/intel-agent/backend/app/main.py
+++ b/intel-agent/backend/app/main.py
@@ -86,7 +86,7 @@ async def start_research(request: ResearchRequest):
campaign_context=request.campaign_context,
status=ResearchStatus.SCRAPING,
)
- save_pack(pack)
+ await asyncio.to_thread(save_pack, pack)
task = asyncio.create_task(_run_research(pack.id, request))
_running_tasks[pack.id] = task
@@ -97,7 +97,7 @@ async def start_research(request: ResearchRequest):
@app.get("/api/research/{pack_id}", response_model=dict)
async def get_research(pack_id: str):
"""Get the current state of a research task."""
- pack = load_pack(pack_id)
+ pack = await asyncio.to_thread(load_pack, pack_id)
if not pack:
raise HTTPException(status_code=404, detail="Research pack not found")
return pack.model_dump(mode="json")
@@ -106,16 +106,16 @@ async def get_research(pack_id: str):
@app.get("/api/research/{pack_id}/report", response_class=PlainTextResponse)
async def get_report(pack_id: str):
"""Get the Markdown report for a completed research pack."""
- pack = load_pack(pack_id)
+ pack = await asyncio.to_thread(load_pack, pack_id)
if not pack:
raise HTTPException(status_code=404, detail="Research pack not found")
- return generate_markdown_report(pack)
+ return await asyncio.to_thread(generate_markdown_report, pack)
@app.get("/api/research", response_model=list)
async def list_research():
"""List all research packs."""
- return list_packs()
+ return await asyncio.to_thread(list_packs)
@app.delete("/api/research/{pack_id}")
@@ -124,7 +124,7 @@ async def delete_research(pack_id: str):
if pack_id in _running_tasks:
_running_tasks[pack_id].cancel()
_running_tasks.pop(pack_id, None)
- if not delete_pack(pack_id):
+ if not await asyncio.to_thread(delete_pack, pack_id):
raise HTTPException(status_code=404, detail="Research pack not found")
return {"deleted": True}
From d393221e68a11ca0c368c2e7fcb6a0e7ee983960 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 04:44:37 +0000
Subject: [PATCH 47/54] fix: CSV injection newline bypass and SSRF DNS
rebinding TOCTOU
- Add \n to CSV escapeCsv dangerous prefix character set
- Eliminate SSRF TOCTOU by resolving DNS once and connecting to validated IP directly with Host header, preventing DNS rebinding attacks
---
intel-agent/backend/app/scraper.py | 53 ++++++++++++-------
src/main/java/io/spring/api/CampaignsApi.java | 2 +-
2 files changed, 34 insertions(+), 21 deletions(-)
diff --git a/intel-agent/backend/app/scraper.py b/intel-agent/backend/app/scraper.py
index eee8e6bf6..dc77a5c02 100644
--- a/intel-agent/backend/app/scraper.py
+++ b/intel-agent/backend/app/scraper.py
@@ -155,49 +155,62 @@ def _check_ip(ip_str: str) -> bool:
return not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved)
-async def _validate_url(url: str) -> bool:
- """Validate URL scheme and that all resolved IPs are public.
+async def _resolve_and_validate(url: str) -> tuple[str, str, int]:
+ """Resolve DNS once, validate all IPs are public, return (validated_ip, hostname, port).
- Uses asyncio.to_thread to avoid blocking the event loop.
+ Eliminates TOCTOU by using the same resolved IP for both validation and connection.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
- return False
+ raise _SsrfBlocked(url)
hostname = parsed.hostname
if not hostname:
- return False
+ raise _SsrfBlocked(url)
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
resolved = await asyncio.to_thread(
- socket.getaddrinfo, hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM,
+ socket.getaddrinfo, hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM,
)
except socket.gaierror:
- return False
+ raise _SsrfBlocked(url)
+ if not resolved:
+ raise _SsrfBlocked(url)
for _, _, _, _, addr in resolved:
if not _check_ip(addr[0]):
- return False
- return bool(resolved)
+ raise _SsrfBlocked(url)
+ validated_ip = resolved[0][4][0]
+ return validated_ip, hostname, port
+
+
+async def _fetch_via_ip(
+ url: str, validated_ip: str, hostname: str, port: int, client: httpx.AsyncClient,
+) -> httpx.Response:
+ """Fetch URL by connecting to the pre-validated IP, setting Host header for TLS/SNI."""
+ parsed = urlparse(url)
+ ip_url = parsed._replace(netloc=f"{validated_ip}:{port}").geturl()
+ req_headers = {**HEADERS, "Host": hostname}
+ return await client.get(
+ ip_url, headers=req_headers, follow_redirects=False, timeout=5,
+ extensions={"sni_hostname": hostname},
+ )
async def _safe_fetch(
url: str, client: httpx.AsyncClient,
) -> httpx.Response:
- """Fetch a URL with SSRF protection: validate DNS, follow redirects safely.
+ """Fetch a URL with SSRF protection: resolve DNS once per request.
- Uses the original URL (preserving hostname for TLS/SNI) but validates all
- resolved IPs before each request. A short per-request timeout limits the
- DNS rebinding window.
+ Resolves DNS, validates all IPs are public, then connects directly to the
+ validated IP with the original Host header. Eliminates DNS rebinding TOCTOU.
"""
- if not await _validate_url(url):
- raise _SsrfBlocked(url)
-
- resp = await client.get(url, headers=HEADERS, follow_redirects=False, timeout=5)
+ validated_ip, hostname, port = await _resolve_and_validate(url)
+ resp = await _fetch_via_ip(url, validated_ip, hostname, port, client)
redirects = 0
while resp.is_redirect and redirects < 5:
location = resp.headers.get("location", "")
redirect_url = urljoin(str(resp.url), location)
- if not await _validate_url(redirect_url):
- raise _SsrfBlocked(redirect_url)
- resp = await client.get(redirect_url, headers=HEADERS, follow_redirects=False, timeout=5)
+ r_ip, r_host, r_port = await _resolve_and_validate(redirect_url)
+ resp = await _fetch_via_ip(redirect_url, r_ip, r_host, r_port, client)
redirects += 1
return resp
diff --git a/src/main/java/io/spring/api/CampaignsApi.java b/src/main/java/io/spring/api/CampaignsApi.java
index e4aec5d79..6442f171b 100644
--- a/src/main/java/io/spring/api/CampaignsApi.java
+++ b/src/main/java/io/spring/api/CampaignsApi.java
@@ -276,7 +276,7 @@ private String escapeCsv(String value) {
if (value == null) {
return "";
}
- if (value.length() > 0 && "=+-@\t\r".indexOf(value.charAt(0)) >= 0) {
+ if (value.length() > 0 && "=+-@\t\r\n".indexOf(value.charAt(0)) >= 0) {
value = "'" + value;
}
if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) {
From 4459087e977829b3d9a053ca8af76102b6bfbfb0 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 04:55:29 +0000
Subject: [PATCH 48/54] fix: SSRF redirect hostname loss and tighten CORS/rate
limiting
- Use original URL (not IP-based resp.url) for resolving relative redirects
- Restrict CORS origins to known frontend ports (configurable via CORS_ORIGINS env)
- Add rate limiting: max 10 research requests/min per IP on POST /api/research
---
intel-agent/backend/app/main.py | 34 +++++++++++++++++++++++++++---
intel-agent/backend/app/scraper.py | 2 +-
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/intel-agent/backend/app/main.py b/intel-agent/backend/app/main.py
index e81597e96..8ff7906d5 100644
--- a/intel-agent/backend/app/main.py
+++ b/intel-agent/backend/app/main.py
@@ -4,12 +4,15 @@
import asyncio
import logging
+import os
+import time
+from collections import defaultdict
from contextlib import asynccontextmanager
from typing import Optional
-from fastapi import BackgroundTasks, FastAPI, HTTPException
+from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import PlainTextResponse
+from fastapi.responses import JSONResponse, PlainTextResponse
from .agent import IntelligenceAgent
from .database import delete_pack, init_db, list_packs, load_pack, save_pack
@@ -42,14 +45,39 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)
+ALLOWED_ORIGINS = os.getenv(
+ "CORS_ORIGINS",
+ "http://localhost:4200,http://localhost:5173,http://127.0.0.1:4200,http://127.0.0.1:5173",
+).split(",")
+
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
+ allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
+# Simple in-memory rate limiter: max 10 research requests per minute per IP
+_rate_limit: dict[str, list[float]] = defaultdict(list)
+RATE_LIMIT_MAX = 10
+RATE_LIMIT_WINDOW = 60 # seconds
+
+
+@app.middleware("http")
+async def rate_limit_middleware(request: Request, call_next):
+ if request.url.path == "/api/research" and request.method == "POST":
+ client_ip = request.client.host if request.client else "unknown"
+ now = time.time()
+ _rate_limit[client_ip] = [t for t in _rate_limit[client_ip] if now - t < RATE_LIMIT_WINDOW]
+ if len(_rate_limit[client_ip]) >= RATE_LIMIT_MAX:
+ return JSONResponse(
+ status_code=429,
+ content={"detail": "Too many research requests. Please try again later."},
+ )
+ _rate_limit[client_ip].append(now)
+ return await call_next(request)
+
async def _run_research(pack_id: str, request: ResearchRequest) -> None:
try:
diff --git a/intel-agent/backend/app/scraper.py b/intel-agent/backend/app/scraper.py
index dc77a5c02..5f50ac65a 100644
--- a/intel-agent/backend/app/scraper.py
+++ b/intel-agent/backend/app/scraper.py
@@ -208,7 +208,7 @@ async def _safe_fetch(
redirects = 0
while resp.is_redirect and redirects < 5:
location = resp.headers.get("location", "")
- redirect_url = urljoin(str(resp.url), location)
+ redirect_url = urljoin(url, location)
r_ip, r_host, r_port = await _resolve_and_validate(redirect_url)
resp = await _fetch_via_ip(redirect_url, r_ip, r_host, r_port, client)
redirects += 1
From 55b9ae7d72c9f9211694e84069829bb2782f025f Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 05:05:22 +0000
Subject: [PATCH 49/54] fix: restore hostname-based fetch for TLS
compatibility, fix redirect chain tracking, evict stale rate-limit keys
- Revert IP-based fetch that broke TLS cert verification on HTTPS sites
- Use hostname-based URL with pre-validation + 5s timeout for SSRF defense
- Track current_url through redirect chain for correct relative Location resolution
- Evict empty IP keys from rate limiter dict to prevent unbounded memory growth
---
intel-agent/backend/app/main.py | 8 ++++--
intel-agent/backend/app/scraper.py | 45 +++++++++++-------------------
2 files changed, 23 insertions(+), 30 deletions(-)
diff --git a/intel-agent/backend/app/main.py b/intel-agent/backend/app/main.py
index 8ff7906d5..c9d04591f 100644
--- a/intel-agent/backend/app/main.py
+++ b/intel-agent/backend/app/main.py
@@ -69,8 +69,12 @@ async def rate_limit_middleware(request: Request, call_next):
if request.url.path == "/api/research" and request.method == "POST":
client_ip = request.client.host if request.client else "unknown"
now = time.time()
- _rate_limit[client_ip] = [t for t in _rate_limit[client_ip] if now - t < RATE_LIMIT_WINDOW]
- if len(_rate_limit[client_ip]) >= RATE_LIMIT_MAX:
+ recent = [t for t in _rate_limit[client_ip] if now - t < RATE_LIMIT_WINDOW]
+ if not recent:
+ _rate_limit.pop(client_ip, None)
+ else:
+ _rate_limit[client_ip] = recent
+ if len(recent) >= RATE_LIMIT_MAX:
return JSONResponse(
status_code=429,
content={"detail": "Too many research requests. Please try again later."},
diff --git a/intel-agent/backend/app/scraper.py b/intel-agent/backend/app/scraper.py
index 5f50ac65a..795e44afa 100644
--- a/intel-agent/backend/app/scraper.py
+++ b/intel-agent/backend/app/scraper.py
@@ -155,10 +155,13 @@ def _check_ip(ip_str: str) -> bool:
return not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved)
-async def _resolve_and_validate(url: str) -> tuple[str, str, int]:
- """Resolve DNS once, validate all IPs are public, return (validated_ip, hostname, port).
+async def _validate_url(url: str) -> None:
+ """Validate URL scheme and that all resolved IPs are public.
- Eliminates TOCTOU by using the same resolved IP for both validation and connection.
+ Raises _SsrfBlocked if the URL targets a private/internal address.
+ The short per-request timeout (5s) in _safe_fetch limits the DNS
+ rebinding window. Combined with rate limiting and restricted CORS,
+ this provides defence-in-depth against SSRF.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
@@ -166,10 +169,9 @@ async def _resolve_and_validate(url: str) -> tuple[str, str, int]:
hostname = parsed.hostname
if not hostname:
raise _SsrfBlocked(url)
- port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
resolved = await asyncio.to_thread(
- socket.getaddrinfo, hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM,
+ socket.getaddrinfo, hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM,
)
except socket.gaierror:
raise _SsrfBlocked(url)
@@ -178,39 +180,26 @@ async def _resolve_and_validate(url: str) -> tuple[str, str, int]:
for _, _, _, _, addr in resolved:
if not _check_ip(addr[0]):
raise _SsrfBlocked(url)
- validated_ip = resolved[0][4][0]
- return validated_ip, hostname, port
-
-
-async def _fetch_via_ip(
- url: str, validated_ip: str, hostname: str, port: int, client: httpx.AsyncClient,
-) -> httpx.Response:
- """Fetch URL by connecting to the pre-validated IP, setting Host header for TLS/SNI."""
- parsed = urlparse(url)
- ip_url = parsed._replace(netloc=f"{validated_ip}:{port}").geturl()
- req_headers = {**HEADERS, "Host": hostname}
- return await client.get(
- ip_url, headers=req_headers, follow_redirects=False, timeout=5,
- extensions={"sni_hostname": hostname},
- )
async def _safe_fetch(
url: str, client: httpx.AsyncClient,
) -> httpx.Response:
- """Fetch a URL with SSRF protection: resolve DNS once per request.
+ """Fetch a URL with SSRF protection.
- Resolves DNS, validates all IPs are public, then connects directly to the
- validated IP with the original Host header. Eliminates DNS rebinding TOCTOU.
+ Validates all resolved IPs are public before each request, then fetches
+ using the original hostname URL so TLS certificate verification works
+ correctly. A 5-second timeout limits the DNS rebinding window.
"""
- validated_ip, hostname, port = await _resolve_and_validate(url)
- resp = await _fetch_via_ip(url, validated_ip, hostname, port, client)
+ await _validate_url(url)
+ current_url = url
+ resp = await client.get(current_url, headers=HEADERS, follow_redirects=False, timeout=5)
redirects = 0
while resp.is_redirect and redirects < 5:
location = resp.headers.get("location", "")
- redirect_url = urljoin(url, location)
- r_ip, r_host, r_port = await _resolve_and_validate(redirect_url)
- resp = await _fetch_via_ip(redirect_url, r_ip, r_host, r_port, client)
+ current_url = urljoin(current_url, location)
+ await _validate_url(current_url)
+ resp = await client.get(current_url, headers=HEADERS, follow_redirects=False, timeout=5)
redirects += 1
return resp
From ed24864a5b88f6ea7e9105e9ec2a530abd5d82da Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 05:22:25 +0000
Subject: [PATCH 50/54] fix: add SOCIAL and ADS channels to detail/list/form
pages, remove plaintext password from migration
- Add SOCIAL and ADS to channelLabels in CampaignDetailPage and CampaignListPage
- Add SOCIAL and ADS options to channel select in CampaignFormPage
- Remove plaintext password from V7 migration SQL comment
---
campaign-admin/src/pages/CampaignDetailPage.tsx | 2 ++
campaign-admin/src/pages/CampaignFormPage.tsx | 2 ++
campaign-admin/src/pages/CampaignListPage.tsx | 2 ++
src/main/resources/db/migration/V7__update_seed_passwords.sql | 4 ++--
4 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/campaign-admin/src/pages/CampaignDetailPage.tsx b/campaign-admin/src/pages/CampaignDetailPage.tsx
index 8a8c0b3e3..a739fd1c2 100644
--- a/campaign-admin/src/pages/CampaignDetailPage.tsx
+++ b/campaign-admin/src/pages/CampaignDetailPage.tsx
@@ -20,6 +20,8 @@ const channelLabels: Record = {
EMAIL: 'Email',
SMS: 'SMS',
PUSH: 'Push Notification',
+ SOCIAL: 'Social Media',
+ ADS: 'Ads',
};
export function CampaignDetailPage() {
diff --git a/campaign-admin/src/pages/CampaignFormPage.tsx b/campaign-admin/src/pages/CampaignFormPage.tsx
index 5d7dca0e0..8f2b93ae5 100644
--- a/campaign-admin/src/pages/CampaignFormPage.tsx
+++ b/campaign-admin/src/pages/CampaignFormPage.tsx
@@ -586,6 +586,8 @@ export function CampaignFormPage() {
+
+
Primary channel for campaign delivery.
diff --git a/campaign-admin/src/pages/CampaignListPage.tsx b/campaign-admin/src/pages/CampaignListPage.tsx
index d0ed47286..4db0412bf 100644
--- a/campaign-admin/src/pages/CampaignListPage.tsx
+++ b/campaign-admin/src/pages/CampaignListPage.tsx
@@ -22,6 +22,8 @@ const channelLabels: Record = {
EMAIL: 'Email',
SMS: 'SMS',
PUSH: 'Push',
+ SOCIAL: 'Social',
+ ADS: 'Ads',
};
export function CampaignListPage() {
diff --git a/src/main/resources/db/migration/V7__update_seed_passwords.sql b/src/main/resources/db/migration/V7__update_seed_passwords.sql
index 88595a523..6d7af4077 100644
--- a/src/main/resources/db/migration/V7__update_seed_passwords.sql
+++ b/src/main/resources/db/migration/V7__update_seed_passwords.sql
@@ -1,5 +1,5 @@
--- Update seed user passwords to complex password: C@mp4ign!Mngr#2026
--- BCrypt hash: $2a$10$LUtBa47o7pSr8/JUK2bx7.ZzTPpzcX9C7eDwjIR6IPGtYr4GpewxS
+-- Update seed user passwords to complex credentials
+-- BCrypt hash for the new password:
UPDATE users SET password = '$2a$10$LUtBa47o7pSr8/JUK2bx7.ZzTPpzcX9C7eDwjIR6IPGtYr4GpewxS'
WHERE id IN ('user-1', 'user-2', 'user-3');
From 62d7bb902820df3eb8956289bebab08d0a711927 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 05:35:35 +0000
Subject: [PATCH 51/54] fix: use regex for underscore replacement in audit log,
clarify migration comment
- Replace .replace('_', ' ') with .replace(/_/g, ' ') for multi-underscore action names
- Point migration comment to README for credentials
---
campaign-admin/src/pages/CampaignDetailPage.tsx | 2 +-
src/main/resources/db/migration/V7__update_seed_passwords.sql | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/campaign-admin/src/pages/CampaignDetailPage.tsx b/campaign-admin/src/pages/CampaignDetailPage.tsx
index a739fd1c2..78a574817 100644
--- a/campaign-admin/src/pages/CampaignDetailPage.tsx
+++ b/campaign-admin/src/pages/CampaignDetailPage.tsx
@@ -780,7 +780,7 @@ export function CampaignDetailPage() {
color: '#1a2744',
}}
>
- {log.action.replace('_', ' ')}
+ {log.action.replace(/_/g, ' ')}
{log.fieldName && (
Date: Wed, 6 May 2026 05:48:17 +0000
Subject: [PATCH 52/54] fix: add threshold-based sweep to prevent unbounded
rate limiter memory growth
Sweep all stale IP entries when dict exceeds 1000 keys, bounding memory
regardless of whether individual IPs make repeat requests.
---
intel-agent/backend/app/main.py | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/intel-agent/backend/app/main.py b/intel-agent/backend/app/main.py
index c9d04591f..d88042c6b 100644
--- a/intel-agent/backend/app/main.py
+++ b/intel-agent/backend/app/main.py
@@ -62,6 +62,14 @@ async def lifespan(app: FastAPI):
_rate_limit: dict[str, list[float]] = defaultdict(list)
RATE_LIMIT_MAX = 10
RATE_LIMIT_WINDOW = 60 # seconds
+_SWEEP_THRESHOLD = 1000
+
+
+def _sweep_stale_entries(now: float) -> None:
+ """Remove all IP entries with no recent timestamps to bound memory."""
+ stale = [ip for ip, ts in _rate_limit.items() if not any(now - t < RATE_LIMIT_WINDOW for t in ts)]
+ for ip in stale:
+ del _rate_limit[ip]
@app.middleware("http")
@@ -69,6 +77,8 @@ async def rate_limit_middleware(request: Request, call_next):
if request.url.path == "/api/research" and request.method == "POST":
client_ip = request.client.host if request.client else "unknown"
now = time.time()
+ if len(_rate_limit) > _SWEEP_THRESHOLD:
+ _sweep_stale_entries(now)
recent = [t for t in _rate_limit[client_ip] if now - t < RATE_LIMIT_WINDOW]
if not recent:
_rate_limit.pop(client_ip, None)
From 0cff8803fed7968ec663b2c1c4f99752c9958a97 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 05:58:31 +0000
Subject: [PATCH 53/54] fix: prevent CSV delivery window corruption when start
time is null
Avoid producing '-endTime' string that triggers formula injection defense.
Output empty string when both times are null.
---
src/main/java/io/spring/api/CampaignsApi.java | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/main/java/io/spring/api/CampaignsApi.java b/src/main/java/io/spring/api/CampaignsApi.java
index 6442f171b..3970f455e 100644
--- a/src/main/java/io/spring/api/CampaignsApi.java
+++ b/src/main/java/io/spring/api/CampaignsApi.java
@@ -257,10 +257,10 @@ public ResponseEntity exportDashboard(@AuthenticationPrincipal User user
csv.append(c.getFulfillmentActionType().name()).append(',');
csv.append(escapeCsv(c.getDisplayPlacement())).append(',');
csv.append(escapeCsv(c.getFrequencyCapType())).append(',');
- String window =
- (c.getDeliveryStartTime() != null ? c.getDeliveryStartTime() : "")
- + "-"
- + (c.getDeliveryEndTime() != null ? c.getDeliveryEndTime() : "");
+ String startTime = c.getDeliveryStartTime() != null ? c.getDeliveryStartTime() : "";
+ String endTime = c.getDeliveryEndTime() != null ? c.getDeliveryEndTime() : "";
+ String window = startTime.isEmpty() && endTime.isEmpty() ? "" :
+ startTime + "-" + endTime;
csv.append(escapeCsv(window)).append(',');
csv.append(escapeCsv(c.getChannel())).append(',');
csv.append(c.getPriority()).append(',');
From 5931428ee68e0218019d3b8c7a2afd8b88ff80da Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 06:10:31 +0000
Subject: [PATCH 54/54] fix: add @AllArgsConstructor to CloneCampaignParam and
BulkStatusUpdateParam
Without it, Jackson cannot populate fields via deserialization since
UNWRAP_ROOT_VALUE is enabled and there are no setters.
---
.../io/spring/application/campaign/BulkStatusUpdateParam.java | 2 ++
.../java/io/spring/application/campaign/CloneCampaignParam.java | 2 ++
2 files changed, 4 insertions(+)
diff --git a/src/main/java/io/spring/application/campaign/BulkStatusUpdateParam.java b/src/main/java/io/spring/application/campaign/BulkStatusUpdateParam.java
index 6c272f0be..ae335549d 100644
--- a/src/main/java/io/spring/application/campaign/BulkStatusUpdateParam.java
+++ b/src/main/java/io/spring/application/campaign/BulkStatusUpdateParam.java
@@ -2,12 +2,14 @@
import com.fasterxml.jackson.annotation.JsonRootName;
import java.util.List;
+import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@JsonRootName("bulk")
@NoArgsConstructor
+@AllArgsConstructor
public class BulkStatusUpdateParam {
private List campaignIds;
private String status;
diff --git a/src/main/java/io/spring/application/campaign/CloneCampaignParam.java b/src/main/java/io/spring/application/campaign/CloneCampaignParam.java
index c23ea6360..c9512e5ba 100644
--- a/src/main/java/io/spring/application/campaign/CloneCampaignParam.java
+++ b/src/main/java/io/spring/application/campaign/CloneCampaignParam.java
@@ -1,12 +1,14 @@
package io.spring.application.campaign;
import com.fasterxml.jackson.annotation.JsonRootName;
+import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@JsonRootName("clone")
@NoArgsConstructor
+@AllArgsConstructor
public class CloneCampaignParam {
private String name;
}