feat: Campaign Management System with CRUD, Analytics Dashboard, Market Intelligence Agent, and React Frontend#142
Conversation
…ard, and React frontend Backend (Java Spring Boot): - Campaign domain model with lifecycle management (DRAFT/ACTIVE/PAUSED/ENDED) - CRUD REST API at /api/campaigns with entitlement-based access control - Campaign decision tracking (ACCEPTED/DECLINED/CLICKED_UNFINISHED) - Analytics endpoints with demographic commonality (segment, age group, region) - Dashboard summary endpoint aggregating all campaign metrics - MyBatis mappers and repository implementations - Database migrations (V3 tables, V4 seed data) - Unit tests for CampaignsApi controller (9 test cases) Frontend (React 18 + TypeScript + Nx): - Login page with JWT authentication - Campaign list view with status filters and indicators - Campaign create/edit forms with status-based field restrictions - Campaign detail page with lifecycle actions (activate, pause, end, archive) - Campaign analytics page with Recharts visualizations (pie, bar charts) - Dashboard with KPIs, performance metrics, and recent campaigns - Access denied screen for non-Marketing entitled users - Playwright e2e test setup
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
…0, block ENDED campaign editing in frontend - Add InvalidCampaignStateException with @ResponseStatus(CONFLICT) for proper 409 responses - Replace IllegalStateException throws in CampaignService with InvalidCampaignStateException - Wrap domain-level IllegalStateException from activate/pause with proper exception - Add ENDED campaign guard in CampaignFormPage showing cannot-edit message - Hide Edit button on CampaignDetailPage for ENDED campaigns
… nav in DashboardPage - Campaign.end() now validates current status is ACTIVE or PAUSED before transitioning - DashboardPage Recent Campaigns table uses React Router Link instead of <a href>
…ete/status actions - findByStatus now accepts includeArchived param, matching findAll behavior - CampaignMapper.xml uses dynamic <if> for archived filter in findByStatus query - CampaignListPage.handleDelete wrapped in try/catch with user feedback - CampaignDetailPage.handleStatusChange and handleDelete wrapped in try/catch
…(N+1 -> 2 queries) - app.spec.tsx: Add AuthProvider wrapper, update assertion to check login page - Add countAllByDecisionForNonArchived aggregate SQL query - Replace N+1 loop in getDashboardSummary with single aggregate query - Dashboard now uses 2 queries instead of 4N+1
…FK and date clearing bugs - Redesign layout: dark blue gradient header bar, white left sidebar with emoji navigation icons, light gray background, breadcrumb navigation - Update login page: themed header bar, dismissible error banner with warning icon and close button - Update all page components (Dashboard, CampaignList, CampaignDetail, CampaignForm, CampaignAnalytics, AccessDenied) with Fiserv color scheme: #1a2744 navy headings, #1d4ed8 primary blue, #6366f1 secondary, #e5e7eb card borders, 0 1px 3px rgba(0,0,0,0.06) shadows - Fix FK constraint violation: delete campaign_decisions before removing DRAFT campaigns (add deleteByCampaignId to repository chain) - Fix date clearing: handle empty string dates to allow clearing previously-set start/end dates via clearStartDate/clearEndDate methods
- Fix e2e test: h1 asserts 'Welcome Back' instead of 'Campaign Manager' - CampaignsApi.listCampaigns: catch IllegalArgumentException on invalid status query param, return 400 instead of 500 - CampaignService.updateCampaign: move valueOf inside try-catch, catch IllegalArgumentException for invalid status strings
- Wrap FulfillmentActionType.valueOf() in try-catch in createCampaign and updateCampaign, returning 409 instead of 500 on invalid values - Change @NotNull to @notblank on fulfillmentActionType to reject empty strings at validation time - Force UTC timezone in parseDate() to prevent date drift on non-UTC JVMs: withZoneUTC() on ISODateTimeFormat parser
…lters Story 2: Dashboard filters (name, date, segment, decision type), CSV export, Remind Me Later as separate KPI, last-updated timestamp Story 3: Personalization tokens, display placement, frequency capping, delivery window scheduling, audience segmentation rules Story 4: Remind-later deferral config, fulfillment workflow URL, decline suppression, confirmation feedback message Backend: Extended Campaign entity, V5 migration, updated MyBatis mappers, CampaignsApi CSV export endpoint, DashboardSummary with remindLater count Frontend: Updated form, detail, dashboard, analytics pages with all new fields
- filterDecision now filters campaigns by fulfillmentActionType - Date filters use .slice(0,10) to compare date-only portions - Results count badge includes filterDecision in condition
…izer for DateTime fields - recordDecision now returns 409 instead of 500 for invalid decision types - campaignToMap/decisionToMap put DateTime objects directly so Jackson's DateTimeSerializer handles UTC normalization consistently
Sanitize values starting with =, +, -, @, tab, or carriage return by prefixing with a single quote to prevent spreadsheet formula injection when CSV is opened in Excel/Google Sheets/LibreOffice.
Disabled follow_redirects and manually follow up to 5 redirects, re-validating each redirect target through _is_safe_url() before fetching. This prevents attackers from using an initial public URL that redirects to internal/metadata endpoints.
- Resolve DNS once via async _resolve_safe() using asyncio.to_thread to avoid blocking the event loop - Pin URL to resolved IP via _pin_url(), setting Host header to original hostname — eliminates TOCTOU gap between DNS check and HTTP request - Same pinning applied to each redirect hop
IP-pinning rewrote URLs to connect to resolved IPs directly, but this broke TLS certificate validation (SNI mismatch). Now uses the original URL (preserving hostname for TLS/SNI) with pre-flight DNS validation via _validate_url() and a short 5s timeout to minimize the rebinding window. Redirects are still validated at each hop.
Modifying an already-applied Flyway migration causes checksum validation failure on existing deployments. Reverted V2 to its original content and created V7__update_seed_passwords.sql with UPDATE statements to change passwords to the complex password.
| -- Update seed user passwords to complex password: C@mp4ign!Mngr#2026 | ||
| -- BCrypt hash: $2a$10$LUtBa47o7pSr8/JUK2bx7.ZzTPpzcX9C7eDwjIR6IPGtYr4GpewxS |
There was a problem hiding this comment.
🔴 V7 migration changes seed user passwords to undocumented hash, breaking login
The V7__update_seed_passwords.sql migration replaces all seed user passwords with a new BCrypt hash ($2a$10$LUtBa47o7pSr8/JUK2bx7...) but the comment on line 2 says "BCrypt hash for the new password:" without ever specifying what the actual plaintext password is. The README.md:50-53 still documents password123 as the credential for all three seed users, which no longer works after this migration runs. This makes the entire application unusable for development — the campaign admin login page (campaign-admin/src/pages/LoginPage.tsx) cannot authenticate, and no developer can determine the correct password without cracking the BCrypt hash.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This is intentional for this demo/workshop application — the original V2 migration also documents the plaintext password in a comment (-- Password for all users is: password123) so developers know the login credentials. These are seed users for a local dev environment, not production credentials. Removing the comment would make it harder for workshop participants to use the app.
There was a problem hiding this comment.
Fixed in ed24864. Removed the plaintext password from the SQL comment. The comment now reads -- Update seed user passwords to complex credentials without exposing the actual password.
There was a problem hiding this comment.
This is a demo/seed-data application — the password was intentionally set by the user and is documented in the README. The migration comment now points to the README for credentials (-- Update seed user passwords (see README for credentials)), keeping the plaintext out of the SQL file while ensuring developers can find it. Fixed in 62d7bb9.
- Step 1 (Setup): Campaign name, description, keywords, product category, priority, dates, channel cards (In-app/Email/SMS/Social/Ads) - Step 2 (Content): Multi-asset builder with Image/Text/HTML types, file upload, rich text editor - Step 3 (Segment): User segment selector modal with searchable grid, chips, estimated reach - Step 4 (Location): Collapsible page sections (Account summary, Make a transfer, Payments, Bill pay) with Website/Mobile placement checkboxes - Step 5 (Review): Read-only summary with edit icons, Preview/Send for approval/Define journey buttons - Updated sidebar navigation with full Fiserv Admin Tool menu structure - Added placeholder pages for new nav items (Campaign Journey, Locations, User Segment, etc.) - Entity selector and breadcrumb navigation
- 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
…lers All async endpoint handlers now use await asyncio.to_thread() for DB calls, matching the pattern already used in _run_research background task.
- 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
- 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
… 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
…aintext 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
…tion comment
- Replace .replace('_', ' ') with .replace(/_/g, ' ') for multi-underscore action names
- Point migration comment to README for credentials
…ry growth Sweep all stale IP entries when dict exceeds 1000 keys, bounding memory regardless of whether individual IPs make repeat requests.
| String window = | ||
| (c.getDeliveryStartTime() != null ? c.getDeliveryStartTime() : "") | ||
| + "-" | ||
| + (c.getDeliveryEndTime() != null ? c.getDeliveryEndTime() : ""); | ||
| csv.append(escapeCsv(window)).append(','); |
There was a problem hiding this comment.
🟡 CSV export corrupts delivery window data when start time is null due to formula injection defense triggering on '-' prefix
In CampaignsApi.java:260-264, the delivery window string is constructed as (startTime ?? "") + "-" + (endTime ?? ""). When deliveryStartTime is null (common for campaigns without time restrictions), the result starts with - (e.g., "-20:00" or just "-"). The escapeCsv method at line 279 treats leading - as a potential CSV formula injection character and prepends a ' — turning the value into '-20:00 or '- in the exported CSV. This silently corrupts exported data for any campaign without a delivery start time configured.
| String window = | |
| (c.getDeliveryStartTime() != null ? c.getDeliveryStartTime() : "") | |
| + "-" | |
| + (c.getDeliveryEndTime() != null ? c.getDeliveryEndTime() : ""); | |
| csv.append(escapeCsv(window)).append(','); | |
| 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(','); |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 0cff880. The delivery window now outputs an empty string when both start and end times are null, avoiding the - prefix that triggered the formula injection defense.
Avoid producing '-endTime' string that triggers formula injection defense. Output empty string when both times are null.
…teParam Without it, Jackson cannot populate fields via deserialization since UNWRAP_ROOT_VALUE is enabled and there are no setters.
Summary
Full-featured Campaign Management System with integrated Market Intelligence Agent, built on Java Spring Boot backend and React 18 + TypeScript + Nx frontend with Fiserv Admin Tool branding.
5-Step Campaign Creation Wizard (NEW)
Enhanced Sidebar Navigation
Campaign Management (Stories 1-4)
Industry Features (8 total)
A/B Testing, Campaign Cloning, Tags & Categories, Multi-Channel Delivery, Priority System, Audit Trail, Calendar View, Bulk Actions
Market Intelligence Agent (Integrated)
Python FastAPI agentic research loop with React UI integrated into sidebar. Scrapes competitor sites, extracts evidence across 8 categories, detects gaps, generates campaign angles with confidence scoring.
Security & Quality
SSRF protection, CSV formula injection defense, SQLite connection leak prevention, async event loop protection, 10 rounds of Devin Review findings addressed.
Review & Testing Checklist for Human
./gradlew bootRun) and login withjanesmith/C@mp4ign!Mngr#2026bobsmithuser sees Access Denied on all protected pagesNotes
/campaigns/new. The old form is preserved at/campaigns/:id/editfor editing existing campaigns./api/researchto it.C@mp4ign!Mngr#2026Link to Devin session: https://partner-workshops.devinenterprise.com/sessions/3b231549f81347d89a211ebf5a75ee87