[object Object]

← back to SDCC Stories

initial scaffold (gitify-all 2026-05-06)

5f05dd42b2bdb2c5e152bb556e03a40f54337bed · 2026-05-06 10:25:12 -0700 · Steve Abrams

Files touched

Diff

commit 5f05dd42b2bdb2c5e152bb556e03a40f54337bed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:25:12 2026 -0700

    initial scaffold (gitify-all 2026-05-06)
---
 .gitignore            |   12 +
 db/stories.db         |  Bin 0 -> 25522176 bytes
 db/stories.db-shm     |  Bin 0 -> 131072 bytes
 db/stories.db-wal     |  Bin 0 -> 51397032 bytes
 ecosystem.config.js   |   12 +
 lib/data-import.js    |  400 ++++++++++++++
 lib/gemini.js         |  207 ++++++++
 lib/instagram-api.js  |  104 ++++
 package-lock.json     | 1373 +++++++++++++++++++++++++++++++++++++++++++++++++
 package.json          |   14 +
 public/css/styles.css |  306 +++++++++++
 public/favicon.svg    |    4 +
 public/index.html     |  103 ++++
 public/js/app.js      |  872 +++++++++++++++++++++++++++++++
 server.js             |  563 ++++++++++++++++++++
 15 files changed, 3970 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e6a9c3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+.env
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+*.bak
diff --git a/db/stories.db b/db/stories.db
new file mode 100644
index 0000000..900c9b7
Binary files /dev/null and b/db/stories.db differ
diff --git a/db/stories.db-shm b/db/stories.db-shm
new file mode 100644
index 0000000..c5bb6db
Binary files /dev/null and b/db/stories.db-shm differ
diff --git a/db/stories.db-wal b/db/stories.db-wal
new file mode 100644
index 0000000..dfcdb47
Binary files /dev/null and b/db/stories.db-wal differ
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..0ef1c35
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,12 @@
+module.exports = {
+  apps: [{
+    name: 'sdcc-stories',
+    script: 'server.js',
+    cwd: '/root/Projects/SDCC-Stories',
+    env: { NODE_ENV: 'production', PORT: 7410 },
+    max_memory_restart: '256M',
+    restart_delay: 3000,
+    max_restarts: 10,
+    autorestart: true
+  }]
+};
diff --git a/lib/data-import.js b/lib/data-import.js
new file mode 100644
index 0000000..7242efa
--- /dev/null
+++ b/lib/data-import.js
@@ -0,0 +1,400 @@
+/**
+ * Data Import — Parse master CSV, split multi-value columns, normalize, score candidates
+ */
+const fs = require('fs');
+const path = require('path');
+
+const MASTER_CSV = '/root/DW-Agents/sdcc/sheet1-master.csv';
+const FRESH_CSV = '/tmp/sdcc-sheet2-fresh.csv';
+const SHEET_URL = 'https://docs.google.com/spreadsheets/d/1sojrO2NtRb9p-W_sJiCOyEbB-1kRr86yU8v0I0-qlnE/export?format=csv&gid=1691047120';
+
+// ── Column Mapping: Full question headers → short field names ──
+const QUESTION_MAP = {
+  'Do you currently have FEDERAL student loans?': 'has_federal',
+  'What type(s) of federal student loans do you have?': 'loan_types',
+  'How much do you currently owe?': 'amount_owed',
+  'what year did you take out your FIRST federal student loan?': 'first_loan_year',
+  'Which repayment plan are you currently enrolled in?': 'repayment_plan',
+  'Are you aware that some repayment plans': 'aware_phaseout',
+  'What is your monthly student loan payment?': 'monthly_payment',
+  'Describe your current repayment status': 'repayment_status',
+  'contacted by the government about your loans being in default': 'contacted_default',
+  'notified by the government about any potential collections': 'notified_collections',
+  'experienced because of your delinquent or defaulted': 'default_experiences',
+  'receive resources about bringing your loans': 'want_resources',
+  'counting on any sort of student loan forgiveness': 'counting_forgiveness',
+  'specify what program you are counting on': 'forgiveness_program',
+  'concerned that rising costs': 'concern_inflation',
+  'emotional impact of your student loan debt': 'emotional_impact',
+  'impacted your ability to afford': 'affordability_impacts',
+  'what would you be spending that money on instead': 'alt_spending',
+  'logged into your student loan servicer': 'logged_in_recently',
+  'information I receive from my student loan servicer': 'trust_servicer',
+  'information I receive from the U.S. Department': 'trust_dept_ed',
+  'What source do you trust': 'trusted_sources',
+  'received conflicting or confusing information': 'conflicting_info',
+  'Which sources did you receive conflicting': 'conflicting_sources',
+  'What type of cancellation or forgiveness did you receive': 'forgiveness_type_received',
+  'What type(s) of loans did you receive cancellation': 'forgiveness_loan_type',
+  'experience with student': 'civic_action_impact',
+  'Are you a voter?': 'is_voter',
+  'vote in the 2024': 'voted_2024',
+  'vote in the 2026': 'plan_vote_2026',
+  'student debt landscape impact how you feel about the government': 'govt_trust_impact',
+  'First name': 'first_name',
+  'Last name': 'last_name',
+  'What is your email': 'email',
+  'What is your zip code': 'zip_code',
+  'How old are you?': 'age',
+  'marital status': 'marital_status',
+  'How many people are in your household': 'household_size',
+  'employment situation': 'employment',
+  'income level': 'income',
+  'race/ethnicity': 'race_ethnicity',
+  'gender': 'gender',
+  'highest level of education': 'education',
+  'share your student loan story': 'story',
+  'share your story with lawmakers': 'sharing_consent',
+  'utm_campaign': 'utm_campaign',
+  'Submitted At': 'submitted_at',
+  'Token': 'token'
+};
+
+// Map a full question header to a short field name
+function mapHeader(header) {
+  // Exact match first
+  if (QUESTION_MAP[header]) return QUESTION_MAP[header];
+  // Partial match (question text is often truncated)
+  const lower = header.toLowerCase();
+  for (const [question, field] of Object.entries(QUESTION_MAP)) {
+    if (lower.includes(question.toLowerCase())) return field;
+  }
+  // Already a short name (from processed CSV)
+  if (/^[a-z_]+$/.test(header)) return header;
+  return header;
+}
+
+// Download fresh data from Google Sheet
+async function downloadFreshData() {
+  const https = require('https');
+  return new Promise((resolve, reject) => {
+    const follow = (url, depth) => {
+      if (depth > 5) return reject(new Error('Too many redirects'));
+      const mod = url.startsWith('https') ? https : require('http');
+      mod.get(url, (res) => {
+        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+          return follow(res.headers.location, depth + 1);
+        }
+        let body = '';
+        res.on('data', c => body += c);
+        res.on('end', () => {
+          if (body.includes('<!DOCTYPE html>')) return reject(new Error('Got HTML instead of CSV — sheet may require auth'));
+          fs.writeFileSync(FRESH_CSV, body);
+          resolve(FRESH_CSV);
+        });
+      }).on('error', reject);
+    };
+    follow(SHEET_URL, 0);
+  });
+}
+
+// ── Multi-Value Column Splitters ──
+
+// Income: "Household - 20,000-50,000" → { income_type: "Household", income_range: "20,000-50,000" }
+function splitIncome(raw) {
+  if (!raw) return { income_type: '', income_range: '', income_amount_low: 0, income_amount_high: 0 };
+  const m = raw.match(/^(Household|Individual)\s*-\s*(.+)$/i);
+  if (!m) return { income_type: '', income_range: raw, income_amount_low: 0, income_amount_high: 0 };
+
+  const type = m[1].trim();
+  const range = m[2].trim();
+
+  let low = 0, high = 0;
+  if (range.includes('Less than')) {
+    low = 0; high = 20000;
+  } else if (range.includes('More than')) {
+    low = 100000; high = 999999;
+  } else {
+    const nums = range.replace(/[$,]/g, '').match(/(\d+)\s*-\s*(\d+)/);
+    if (nums) { low = parseInt(nums[1]); high = parseInt(nums[2]); }
+  }
+  return { income_type: type, income_range: range, income_amount_low: low, income_amount_high: high };
+}
+
+// Amount owed: "$100,001 - $150,000" → { amount_low, amount_high, amount_display }
+function splitAmount(raw) {
+  if (!raw) return { amount_low: 0, amount_high: 0, amount_display: '' };
+  const clean = raw.replace(/[$,]/g, '');
+
+  if (raw.includes('More than')) {
+    return { amount_low: 200000, amount_high: 999999, amount_display: raw };
+  }
+  if (raw.includes('Less than')) {
+    return { amount_low: 0, amount_high: 20000, amount_display: raw };
+  }
+  const nums = clean.match(/(\d+)\s*-\s*(\d+)/);
+  if (nums) {
+    return { amount_low: parseInt(nums[1]), amount_high: parseInt(nums[2]), amount_display: raw };
+  }
+  return { amount_low: 0, amount_high: 0, amount_display: raw };
+}
+
+// Affordability impacts: comma-separated list → array + individual boolean columns
+function splitAffordability(raw) {
+  if (!raw) return { affordability_list: [], affordability_count: 0 };
+  const items = raw.split(',').map(s => s.trim()).filter(Boolean);
+  return {
+    affordability_list: items,
+    affordability_count: items.length,
+    aff_food: items.some(i => /food|groceries/i.test(i)),
+    aff_healthcare: items.some(i => /health/i.test(i)),
+    aff_housing: items.some(i => /rent|mortgage|hous/i.test(i)),
+    aff_savings: items.some(i => /saving|retirement|invest/i.test(i)),
+    aff_childcare: items.some(i => /child/i.test(i)),
+    aff_education: items.some(i => /tuition|school/i.test(i)),
+    aff_debt: items.some(i => /credit|debt/i.test(i)),
+    aff_charity: items.some(i => /charit|donat/i.test(i))
+  };
+}
+
+// Loan types: comma-separated → array
+function splitLoanTypes(raw) {
+  if (!raw) return { loan_type_list: [], has_undergraduate: false, has_graduate: false, has_parent_plus: false, has_perkins: false, has_consolidation: false };
+  const items = raw.split(',').map(s => s.trim()).filter(Boolean);
+  return {
+    loan_type_list: items,
+    has_undergraduate: items.some(i => /undergraduate/i.test(i)),
+    has_graduate: items.some(i => /graduate\s*plus|grad/i.test(i)),
+    has_parent_plus: items.some(i => /parent/i.test(i)),
+    has_perkins: items.some(i => /perkins/i.test(i)),
+    has_consolidation: items.some(i => /consolidat/i.test(i))
+  };
+}
+
+// ── Candidate Scoring ──
+// Higher score = better story candidate
+function scoreCandidate(record) {
+  let score = 0;
+
+  // P: Emotional impact (richest stories have detailed emotional narratives)
+  const emoLen = (record.emotional_impact || '').length;
+  if (emoLen > 200) score += 30;
+  else if (emoLen > 100) score += 20;
+  else if (emoLen > 30) score += 10;
+
+  // Q: Affordability impacts (more impacts = more compelling)
+  score += Math.min((record.affordability_count || 0) * 5, 25);
+
+  // R: Alt spending (concrete alternatives make stories relatable)
+  const altLen = (record.alt_spending || '').length;
+  if (altLen > 100) score += 20;
+  else if (altLen > 30) score += 10;
+
+  // Story field present
+  if ((record.story || '').length > 50) score += 15;
+
+  // Has consent
+  if (/yes/i.test(record.sharing_consent)) score += 10;
+  if (/yes/i.test(record.press_ready)) score += 10;
+
+  // Key quote exists
+  if ((record.key_quote || '').length > 20) score += 10;
+
+  // Has name + zip (needed for lawmaker mapping)
+  if (record.first_name) score += 5;
+  if (record.zip_code) score += 5;
+
+  // Amount owed (higher debt = more impactful)
+  if (record.amount_high > 100000) score += 10;
+  else if (record.amount_high > 50000) score += 5;
+
+  // Voter status (civic engagement adds credibility)
+  if (/yes/i.test(record.is_voter)) score += 5;
+
+  return Math.min(score, 100);
+}
+
+// ── Candidate Tier ──
+function getTier(score) {
+  if (score >= 70) return 'A';  // Press + social media + lawmakers
+  if (score >= 45) return 'B';  // Social media + lawmakers
+  if (score >= 25) return 'C';  // Social media only
+  return 'D';  // Needs enrichment
+}
+
+// ── Parse CSV Manually (no external deps) ──
+function parseCSV(text) {
+  const rows = [];
+  let current = '';
+  let inQuotes = false;
+  const lines = [];
+
+  // Split into lines respecting quoted fields
+  for (let i = 0; i < text.length; i++) {
+    const ch = text[i];
+    if (ch === '"') {
+      if (inQuotes && text[i + 1] === '"') { current += '"'; i++; }
+      else { inQuotes = !inQuotes; current += ch; }
+    } else if ((ch === '\n' || ch === '\r') && !inQuotes) {
+      if (current.length > 0) lines.push(current);
+      current = '';
+      if (ch === '\r' && text[i + 1] === '\n') i++;
+    } else {
+      current += ch;
+    }
+  }
+  if (current.length > 0) lines.push(current);
+
+  if (lines.length === 0) return [];
+
+  // Parse header
+  const headers = splitCSVLine(lines[0]);
+
+  // Parse data rows
+  for (let i = 1; i < lines.length; i++) {
+    const vals = splitCSVLine(lines[i]);
+    const row = {};
+    for (let j = 0; j < headers.length; j++) {
+      row[headers[j]] = (vals[j] || '').replace(/^"|"$/g, '').trim();
+    }
+    rows.push(row);
+  }
+  return rows;
+}
+
+function splitCSVLine(line) {
+  const result = [];
+  let current = '';
+  let inQuotes = false;
+  for (let i = 0; i < line.length; i++) {
+    const ch = line[i];
+    if (ch === '"') {
+      if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
+      else { inQuotes = !inQuotes; }
+    } else if (ch === ',' && !inQuotes) {
+      result.push(current);
+      current = '';
+    } else {
+      current += ch;
+    }
+  }
+  result.push(current);
+  return result;
+}
+
+// ── Main Import Function ──
+function importData(csvPath) {
+  // Try fresh CSV first, then master CSV
+  const filePath = csvPath || (fs.existsSync(FRESH_CSV) ? FRESH_CSV : MASTER_CSV);
+  const text = fs.readFileSync(filePath, 'utf8');
+  const rawRows = parseCSV(text);
+
+  // Detect if headers are full questions or short names, and normalize
+  if (rawRows.length > 0) {
+    const firstRow = rawRows[0];
+    const keys = Object.keys(firstRow);
+    // If first key looks like a question, remap all rows
+    if (keys[0] && keys[0].length > 30) {
+      const headerMap = {};
+      for (const k of keys) headerMap[k] = mapHeader(k);
+      for (const row of rawRows) {
+        for (const [oldKey, newKey] of Object.entries(headerMap)) {
+          if (oldKey !== newKey) {
+            row[newKey] = row[oldKey];
+            delete row[oldKey];
+          }
+        }
+      }
+    }
+  }
+
+  const processed = rawRows.map((raw, idx) => {
+    const income = splitIncome(raw.income);
+    const amount = splitAmount(raw.amount_owed);
+    const aff = splitAffordability(raw.affordability_impacts);
+    const loans = splitLoanTypes(raw.loan_types);
+
+    const record = {
+      id: idx + 1,
+      // Core demographics
+      first_name: raw.first_name || '',
+      last_name: raw.last_name || '',
+      email: raw.email || '',
+      zip_code: raw.zip_code || '',
+      age: raw.age || '',
+      marital_status: raw.marital_status || '',
+      household_size: raw.household_size || '',
+      employment: raw.employment || '',
+      race_ethnicity: raw.race_ethnicity || '',
+      gender: raw.gender || '',
+      education: raw.education || '',
+
+      // Income — SPLIT
+      income_raw: raw.income || '',
+      ...income,
+
+      // Loan info
+      has_federal: raw.has_federal || '',
+      loan_types_raw: raw.loan_types || '',
+      ...loans,
+      first_loan_year: raw.first_loan_year || '',
+      repayment_plan: raw.repayment_plan || '',
+      monthly_payment: raw.monthly_payment || '',
+      repayment_status: raw.repayment_status || '',
+
+      // Amount — SPLIT
+      amount_owed_raw: raw.amount_owed || '',
+      ...amount,
+
+      // P, Q, R — THE STORY ESSENTIALS
+      emotional_impact: raw.emotional_impact || '',      // P
+      affordability_impacts_raw: raw.affordability_impacts || '',  // Q (raw)
+      ...aff,                                            // Q (split)
+      alt_spending: raw.alt_spending || '',               // R
+
+      // Story
+      story: raw.story || '',
+      key_quote: raw.key_quote || '',
+      story_type: raw.story_type || '',
+      story_category: raw.story_category || '',
+      platform_fit: raw.platform_fit || '',
+      story_strength: parseInt(raw.story_strength) || 0,
+      press_pitch_angle: raw.press_pitch_angle || '',
+
+      // Consent & readiness
+      sharing_consent: raw.sharing_consent || '',
+      press_ready: raw.press_ready || '',
+
+      // Civic
+      is_voter: raw.is_voter || '',
+      voted_2024: raw.voted_2024 || '',
+      plan_vote_2026: raw.plan_vote_2026 || '',
+      civic_action_impact: raw.civic_action_impact || '',
+      govt_trust_impact: raw.govt_trust_impact || '',
+
+      // Trust
+      trust_servicer: raw.trust_servicer || '',
+      trust_dept_ed: raw.trust_dept_ed || '',
+      trusted_sources: raw.trusted_sources || '',
+
+      // Other
+      aware_phaseout: raw.aware_phaseout || '',
+      counting_forgiveness: raw.counting_forgiveness || '',
+      forgiveness_program: raw.forgiveness_program || '',
+      concern_inflation: raw.concern_inflation || '',
+      default_experiences: raw.default_experiences || '',
+      utm_campaign: raw.utm_campaign || '',
+      submitted_at: raw.submitted_at || ''
+    };
+
+    // Score and tier
+    record.candidate_score = scoreCandidate(record);
+    record.tier = getTier(record.candidate_score);
+
+    return record;
+  });
+
+  return processed;
+}
+
+module.exports = { importData, downloadFreshData, scoreCandidate, getTier, splitIncome, splitAmount, splitAffordability, splitLoanTypes };
diff --git a/lib/gemini.js b/lib/gemini.js
new file mode 100644
index 0000000..567b516
--- /dev/null
+++ b/lib/gemini.js
@@ -0,0 +1,207 @@
+/**
+ * Gemini Integration — Story generation + social media graphics
+ * Uses gemini-2.0-flash for text, gemini-2.5-flash-image for graphics
+ */
+const https = require('https');
+const fs = require('fs');
+
+const GEMINI_TEXT_KEY = 'AIzaSyAO0rLKwtJUKcf3zVmKstBS4udct4QejMo';
+const GEMINI_IMAGE_KEY = 'AIzaSyBHmT3GLHlzjvFVngT93bvEPQ1AZpzS2ic';
+const TEXT_MODEL = 'gemini-2.0-flash';
+const IMAGE_MODEL = 'gemini-2.5-flash-image';
+const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
+
+// SDCC Brand Colors
+const COLORS = {
+  action: '#ef4444',    // Red — Lane A
+  policy: '#3b82f6',    // Blue — Lane B
+  resources: '#22c55e', // Green — Lane C
+  bg: '#0a0a12',
+  surface: '#12121e',
+  text: '#fafafa',
+  secondary: '#eab308', // Gold accent
+  muted: '#71717a'
+};
+
+function geminiRequest(model, key, body) {
+  return new Promise((resolve, reject) => {
+    const payload = JSON.stringify(body);
+    const url = `${BASE_URL}/${model}:generateContent?key=${key}`;
+    const parsed = new URL(url);
+
+    const req = https.request({
+      hostname: parsed.hostname,
+      path: parsed.pathname + parsed.search,
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
+    }, (res) => {
+      let data = '';
+      res.on('data', c => data += c);
+      res.on('end', () => {
+        try { resolve(JSON.parse(data)); }
+        catch (e) { reject(new Error('Gemini parse error: ' + data.slice(0, 200))); }
+      });
+    });
+    req.on('error', reject);
+    req.write(payload);
+    req.end();
+  });
+}
+
+/**
+ * Generate a social media story from borrower data
+ * Returns: { story_text, instagram_caption, x_post, hashtags }
+ */
+async function generateStory(candidate) {
+  const prompt = `You are writing a compelling 1-2 sentence social media story for a student debt advocacy organization.
+The goal is to humanize the student debt crisis and encourage others to share their stories.
+
+BORROWER DATA:
+- Name: ${candidate.first_name || 'Anonymous'}
+- Age: ${candidate.age || 'Unknown'}
+- Amount owed: ${candidate.amount_owed_raw || candidate.amount_display || 'undisclosed'}
+- Emotional impact (P): ${(candidate.emotional_impact || '').slice(0, 500)}
+- What they can't afford (Q): ${(candidate.affordability_impacts_raw || '').slice(0, 300)}
+- What they'd spend money on instead (R): ${(candidate.alt_spending || '').slice(0, 300)}
+- Their story: ${(candidate.story || '').slice(0, 500)}
+- Key quote: ${(candidate.key_quote || '').slice(0, 200)}
+- Loan type: ${candidate.loan_types_raw || ''}
+- Repayment plan: ${candidate.repayment_plan || ''}
+
+Generate EXACTLY this JSON (no markdown, no backticks):
+{
+  "story_one_liner": "A powerful 1-sentence story (max 140 chars) using their name and amount, suitable for a graphic overlay",
+  "instagram_caption": "2-3 sentence Instagram caption that tells their story emotionally. End with a CTA to share your own story. Include line breaks.",
+  "x_post": "Concise X/Twitter post under 280 characters. Impactful, shareable. Include the person's first name.",
+  "hashtags": ["5-7 relevant hashtags without # symbol"],
+  "story_category": "one of: hardship, resilience, injustice, hope, call_to_action"
+}
+
+RULES:
+- Use their REAL first name (not "Anonymous" unless no name given)
+- Include their debt amount naturally in the narrative
+- Focus on the HUMAN impact, not policy
+- Make it shareable — people should feel compelled to repost
+- Instagram caption should have 2-3 paragraph breaks for readability
+- X post must be under 280 characters total including hashtags`;
+
+  const result = await geminiRequest(TEXT_MODEL, GEMINI_TEXT_KEY, {
+    contents: [{ parts: [{ text: prompt }] }],
+    generationConfig: { temperature: 0.8, maxOutputTokens: 500 }
+  });
+
+  const text = result?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+  // Extract JSON from response (handle markdown wrapping)
+  const jsonMatch = text.match(/\{[\s\S]*\}/);
+  if (!jsonMatch) return { story_one_liner: '', instagram_caption: '', x_post: '', hashtags: [], story_category: 'hardship' };
+
+  try {
+    return JSON.parse(jsonMatch[0]);
+  } catch (e) {
+    return { story_one_liner: text.slice(0, 140), instagram_caption: '', x_post: '', hashtags: [], story_category: 'hardship' };
+  }
+}
+
+/**
+ * Generate a social media graphic with SDCC branding
+ * Returns: { imageBuffer, text }
+ */
+async function generateGraphic(candidate, storyText, options = {}) {
+  const lane = options.lane || 'action';
+  const laneColor = COLORS[lane] || COLORS.action;
+  const laneLabel = lane === 'action' ? 'BORROWER STORY' : lane === 'policy' ? 'POLICY IMPACT' : 'RESOURCES';
+
+  const prompt = `Create a 1080x1080 social media graphic for a student debt advocacy organization.
+
+DESIGN REQUIREMENTS:
+- Background: Dark gradient from ${COLORS.bg} to ${COLORS.surface}
+- Border: 4px solid ${laneColor} (${lane} lane)
+- Top banner: ${laneColor} strip with white text "${laneLabel}"
+- Main text area: Large white text with the story quote
+- Bottom section: Name, amount owed, and organization watermark
+- Typography: Clean, modern, sans-serif. Story text should be the hero element.
+- Mood: Serious but hopeful. Professional advocacy graphics.
+
+CONTENT:
+- Quote/Story: "${storyText}"
+- Name: ${candidate.first_name || 'Anonymous'}
+- Amount: ${candidate.amount_owed_raw || candidate.amount_display || ''}
+- Organization: Student Debt Crisis Center
+
+STYLE:
+- Think editorial/news graphic style
+- No stock photos, just typography + color
+- The quote should be the dominant visual element
+- Include quotation marks around the story text
+- Small "studentdebtcrisis.org" watermark bottom right
+
+Do NOT include any faces or photographs. Text + color only.`;
+
+  try {
+    const result = await geminiRequest(IMAGE_MODEL, GEMINI_IMAGE_KEY, {
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
+    });
+
+    const parts = result?.candidates?.[0]?.content?.parts || [];
+    let imageBuffer = null;
+    let responseText = '';
+
+    for (const part of parts) {
+      if (part.inlineData) {
+        imageBuffer = Buffer.from(part.inlineData.data, 'base64');
+      }
+      if (part.text) {
+        responseText = part.text;
+      }
+    }
+
+    return { imageBuffer, text: responseText };
+  } catch (e) {
+    console.error('Gemini image generation error:', e.message);
+    return { imageBuffer: null, text: e.message };
+  }
+}
+
+/**
+ * Generate a lawmaker-targeted graphic
+ */
+async function generateLawmakerGraphic(candidate, lawmaker, storyText) {
+  const prompt = `Create a 1080x1080 advocacy graphic targeting a specific lawmaker.
+
+DESIGN:
+- Background: Dark gradient ${COLORS.bg} to ${COLORS.surface}
+- Top: ${COLORS.policy} (blue) banner with "YOUR CONSTITUENT'S STORY"
+- Middle: Large quote text in white
+- Bottom left: Borrower name + amount owed
+- Bottom right: "Rep. ${lawmaker.name}" + "${lawmaker.district}" + "${lawmaker.party}"
+- Border: 4px ${COLORS.policy} blue
+- Watermark: studentdebtcrisis.org
+
+CONTENT:
+- Quote: "${storyText}"
+- Borrower: ${candidate.first_name}, ${candidate.zip_code || ''}
+- Amount: ${candidate.amount_owed_raw || ''}
+- Lawmaker: Rep. ${lawmaker.name} (${lawmaker.party}-${lawmaker.state})
+- District: ${lawmaker.district}
+
+STYLE: Editorial, serious, professional. No photos. Typography + color only.`;
+
+  try {
+    const result = await geminiRequest(IMAGE_MODEL, GEMINI_IMAGE_KEY, {
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
+    });
+
+    const parts = result?.candidates?.[0]?.content?.parts || [];
+    let imageBuffer = null;
+    for (const part of parts) {
+      if (part.inlineData) imageBuffer = Buffer.from(part.inlineData.data, 'base64');
+    }
+    return { imageBuffer };
+  } catch (e) {
+    return { imageBuffer: null };
+  }
+}
+
+module.exports = { generateStory, generateGraphic, generateLawmakerGraphic, COLORS };
diff --git a/lib/instagram-api.js b/lib/instagram-api.js
new file mode 100644
index 0000000..a4b7842
--- /dev/null
+++ b/lib/instagram-api.js
@@ -0,0 +1,104 @@
+/**
+ * Instagram API — Meta Graph API integration (cloned from SmokeShop pattern)
+ * OAuth 2.0 flow → long-lived token → 2-step media publishing
+ */
+const https = require('https');
+
+const META_API_VERSION = 'v18.0';
+
+// Exchange short-lived code for access token
+async function exchangeCodeForToken(appId, appSecret, redirectUri, code) {
+  const params = new URLSearchParams({
+    client_id: appId,
+    client_secret: appSecret,
+    grant_type: 'authorization_code',
+    redirect_uri: redirectUri,
+    code
+  });
+
+  return apiGet(`https://graph.facebook.com/${META_API_VERSION}/oauth/access_token?${params}`);
+}
+
+// Exchange short-lived token for 60-day long-lived token
+async function getLongLivedToken(appId, appSecret, shortToken) {
+  const params = new URLSearchParams({
+    grant_type: 'fb_exchange_token',
+    client_id: appId,
+    client_secret: appSecret,
+    fb_exchange_token: shortToken
+  });
+
+  return apiGet(`https://graph.facebook.com/${META_API_VERSION}/oauth/access_token?${params}`);
+}
+
+// Get Instagram Business Account ID from Facebook Page
+async function getIGBusinessAccount(accessToken) {
+  const pages = await apiGet(`https://graph.facebook.com/${META_API_VERSION}/me/accounts?access_token=${accessToken}`);
+  if (!pages.data || pages.data.length === 0) return null;
+
+  const pageId = pages.data[0].id;
+  const igAccount = await apiGet(`https://graph.facebook.com/${META_API_VERSION}/${pageId}?fields=instagram_business_account&access_token=${accessToken}`);
+  return igAccount.instagram_business_account?.id || null;
+}
+
+// Publish to Instagram (2-step: create container → publish)
+async function publishToInstagram(igAccountId, accessToken, imageUrl, caption) {
+  // Step 1: Create media container
+  const container = await apiPost(`https://graph.facebook.com/${META_API_VERSION}/${igAccountId}/media`, {
+    image_url: imageUrl,
+    caption,
+    access_token: accessToken
+  });
+
+  if (!container.id) throw new Error('Failed to create media container: ' + JSON.stringify(container));
+
+  // Wait for processing
+  await new Promise(r => setTimeout(r, 3000));
+
+  // Step 2: Publish
+  const published = await apiPost(`https://graph.facebook.com/${META_API_VERSION}/${igAccountId}/media_publish`, {
+    creation_id: container.id,
+    access_token: accessToken
+  });
+
+  return published;
+}
+
+// ── HTTP helpers ──
+function apiGet(url) {
+  return new Promise((resolve, reject) => {
+    https.get(url, (res) => {
+      let data = '';
+      res.on('data', c => data += c);
+      res.on('end', () => {
+        try { resolve(JSON.parse(data)); }
+        catch (e) { resolve({ error: data }); }
+      });
+    }).on('error', reject);
+  });
+}
+
+function apiPost(url, body) {
+  return new Promise((resolve, reject) => {
+    const payload = JSON.stringify(body);
+    const parsed = new URL(url);
+    const req = https.request({
+      hostname: parsed.hostname,
+      path: parsed.pathname + parsed.search,
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
+    }, (res) => {
+      let data = '';
+      res.on('data', c => data += c);
+      res.on('end', () => {
+        try { resolve(JSON.parse(data)); }
+        catch (e) { resolve({ error: data }); }
+      });
+    });
+    req.on('error', reject);
+    req.write(payload);
+    req.end();
+  });
+}
+
+module.exports = { exchangeCodeForToken, getLongLivedToken, getIGBusinessAccount, publishToInstagram };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..fea98a6
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1373 @@
+{
+  "name": "sdcc-stories",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "sdcc-stories",
+      "version": "1.0.0",
+      "dependencies": {
+        "better-sqlite3": "^12.6.2",
+        "cors": "^2.8.6",
+        "express": "^4.18.2",
+        "helmet": "^8.1.0",
+        "multer": "^2.1.1",
+        "node-cron": "^4.2.1"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/better-sqlite3": {
+      "version": "12.6.2",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
+      "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.1"
+      },
+      "engines": {
+        "node": "20.x || 22.x || 23.x || 24.x || 25.x"
+      }
+    },
+    "node_modules/bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.4",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+      "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.14.0",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "license": "ISC"
+    },
+    "node_modules/concat-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+      "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+      "engines": [
+        "node >= 6.0"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.0.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "license": "MIT",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "license": "(MIT OR WTFPL)",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+      "license": "MIT"
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "license": "MIT"
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+      "license": "MIT"
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/multer": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+      "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.6.0",
+        "concat-stream": "^2.0.0",
+        "type-is": "^1.6.18"
+      },
+      "engines": {
+        "node": ">= 10.16.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/napi-build-utils": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-abi": {
+      "version": "3.87.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
+      "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/node-cron": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
+      "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.12",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+      "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+      "license": "MIT"
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+      "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^2.0.0",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/pump": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+      "license": "MIT",
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6acf186
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "sdcc-stories",
+  "version": "1.0.0",
+  "description": "SDCC Story Engine — borrower stories, social media, lawmaker outreach",
+  "main": "server.js",
+  "dependencies": {
+    "better-sqlite3": "^12.6.2",
+    "cors": "^2.8.6",
+    "express": "^4.18.2",
+    "helmet": "^8.1.0",
+    "multer": "^2.1.1",
+    "node-cron": "^4.2.1"
+  }
+}
diff --git a/public/css/styles.css b/public/css/styles.css
new file mode 100644
index 0000000..e527f61
--- /dev/null
+++ b/public/css/styles.css
@@ -0,0 +1,306 @@
+/* SDCC Story Engine — Light Theme, Large Text for Readability */
+:root {
+  --bg: #ffffff;
+  --surface: #f8f9fa;
+  --surface-el: #f0f1f3;
+  --primary: #2563eb;
+  --primary-hover: #1d4ed8;
+  --secondary: #b45309;
+  --lane-a: #dc2626;
+  --lane-b: #2563eb;
+  --lane-c: #16a34a;
+  --success: #16a34a;
+  --warning: #d97706;
+  --error: #dc2626;
+  --text: #111111;
+  --text-secondary: #333333;
+  --text-muted: #555555;
+  --border: #d1d5db;
+  --radius: 8px;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+  background: var(--bg);
+  color: var(--text);
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+  font-size: 18px;
+  line-height: 1.5;
+  min-height: 100vh;
+}
+
+header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20px 28px;
+  background: var(--surface);
+  border-bottom: 2px solid var(--border);
+}
+
+header h1 { font-size: 26px; color: var(--text); }
+.subtitle { color: var(--text-muted); font-size: 16px; margin-left: 10px; }
+.header-right { display: flex; align-items: center; gap: 14px; }
+
+/* Stats Bar */
+.stats-bar {
+  display: flex;
+  gap: 4px;
+  padding: 14px 28px;
+  background: var(--surface);
+  border-bottom: 2px solid var(--border);
+  overflow-x: auto;
+}
+.stat-card {
+  background: var(--surface-el);
+  padding: 12px 18px;
+  border-radius: var(--radius);
+  text-align: center;
+  min-width: 110px;
+  flex: 1;
+}
+.stat-card .stat-value { font-size: 26px; font-weight: 800; color: var(--secondary); }
+.stat-card .stat-label { font-size: 14px; color: var(--text-muted); text-transform: uppercase; font-weight: 600; }
+
+/* Tabs */
+.tabs {
+  display: flex;
+  gap: 4px;
+  padding: 10px 28px;
+  background: var(--surface);
+  border-bottom: 3px solid var(--border);
+}
+.tab {
+  padding: 10px 20px;
+  background: none;
+  border: none;
+  color: var(--text-muted);
+  cursor: pointer;
+  font-size: 17px;
+  font-weight: 700;
+  border-bottom: 3px solid transparent;
+  transition: all 0.15s;
+}
+.tab:hover { color: var(--text); }
+.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
+
+.tab-content { display: none; padding: 24px 28px; }
+.tab-content.active { display: block; }
+
+/* Buttons */
+.btn {
+  padding: 10px 20px;
+  border: none;
+  border-radius: var(--radius);
+  cursor: pointer;
+  font-size: 16px;
+  font-weight: 700;
+  transition: all 0.15s;
+  color: white;
+  background: var(--primary);
+}
+.btn:hover { background: var(--primary-hover); }
+.btn-sm { padding: 8px 14px; font-size: 15px; }
+.btn-action { background: var(--lane-a); }
+.btn-action:hover { background: #b91c1c; }
+.btn-policy { background: var(--lane-b); }
+.btn-resource { background: var(--lane-c); }
+.btn-resource:hover { background: #15803d; }
+.btn-ghost { background: transparent; border: 2px solid var(--border); color: var(--text-secondary); }
+.btn-ghost:hover { background: var(--surface-el); }
+
+/* Badge */
+.badge {
+  display: inline-block;
+  padding: 4px 12px;
+  border-radius: 12px;
+  font-size: 14px;
+  font-weight: 700;
+}
+.badge-a { background: var(--lane-a); color: white; }
+.badge-b { background: var(--lane-b); color: white; }
+.badge-c { background: var(--lane-c); color: white; }
+.badge-d { background: #888; color: white; }
+.badge-draft { background: var(--surface-el); color: var(--text-secondary); border: 1px solid var(--border); }
+.badge-scheduled { background: var(--warning); color: #000; }
+.badge-posted { background: var(--success); color: white; }
+
+/* Controls */
+.controls {
+  display: flex;
+  gap: 14px;
+  align-items: center;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+}
+.filter-group { display: flex; align-items: center; gap: 8px; }
+.filter-group label { color: var(--text-muted); font-size: 16px; font-weight: 600; }
+
+select, input[type="text"] {
+  background: white;
+  border: 2px solid var(--border);
+  color: var(--text);
+  padding: 10px 14px;
+  border-radius: var(--radius);
+  font-size: 16px;
+}
+select:focus, input:focus { outline: none; border-color: var(--primary); }
+.hint { color: var(--text-muted); font-size: 15px; }
+
+/* Candidate List */
+.candidate-count { color: var(--text-muted); font-size: 16px; margin-bottom: 10px; font-weight: 600; }
+
+.candidate-card {
+  background: var(--surface);
+  border: 2px solid var(--border);
+  border-radius: var(--radius);
+  padding: 16px;
+  margin-bottom: 10px;
+  cursor: pointer;
+  transition: all 0.15s;
+  display: grid;
+  grid-template-columns: 48px 150px 150px 1fr 120px 100px;
+  align-items: center;
+  gap: 14px;
+}
+.candidate-card:hover { border-color: var(--primary); background: var(--surface-el); }
+
+.score-circle {
+  width: 44px; height: 44px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 800;
+  font-size: 17px;
+  color: white;
+}
+
+.candidate-name { font-weight: 700; font-size: 17px; }
+.candidate-amount { color: var(--secondary); font-weight: 700; font-size: 17px; }
+.candidate-snippet { color: var(--text-secondary); font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.candidate-meta { font-size: 14px; color: var(--text-muted); font-weight: 500; }
+
+/* Pagination */
+.pagination { display: flex; justify-content: center; gap: 6px; margin-top: 20px; }
+.pagination button { background: var(--surface-el); border: 2px solid var(--border); color: var(--text-secondary); padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 16px; font-weight: 600; }
+.pagination button.active { background: var(--primary); color: white; border-color: var(--primary); }
+
+/* Story Cards */
+.story-card {
+  background: var(--surface);
+  border: 2px solid var(--border);
+  border-radius: var(--radius);
+  padding: 20px;
+  margin-bottom: 14px;
+}
+.story-card .story-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
+.story-card .story-quote { font-style: italic; color: var(--text-secondary); margin: 10px 0; padding: 12px; border-left: 4px solid var(--lane-a); font-size: 17px; }
+.story-card .story-actions { display: flex; gap: 10px; margin-top: 14px; }
+.story-card .story-image { max-width: 250px; border-radius: var(--radius); margin-top: 10px; }
+
+/* Calendar */
+.calendar-grid {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 6px;
+}
+.calendar-day {
+  background: var(--surface);
+  border: 2px solid var(--border);
+  border-radius: var(--radius);
+  padding: 10px;
+  min-height: 110px;
+}
+.calendar-day .day-header { font-size: 14px; color: var(--text-muted); margin-bottom: 8px; font-weight: 700; }
+.calendar-slot {
+  background: var(--surface-el);
+  border-radius: 6px;
+  padding: 6px 8px;
+  margin-bottom: 6px;
+  font-size: 14px;
+  font-weight: 500;
+}
+.calendar-slot.instagram { border-left: 4px solid var(--lane-a); }
+.calendar-slot.x { border-left: 4px solid var(--lane-b); }
+
+/* Heat Map */
+.heatmap-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
+.heatmap-card {
+  background: var(--surface);
+  border: 2px solid var(--border);
+  border-radius: var(--radius);
+  padding: 16px;
+  text-align: center;
+}
+.heatmap-card .district { font-weight: 800; color: var(--secondary); font-size: 18px; }
+.heatmap-card .rep-name { font-size: 16px; }
+.heatmap-card .count { font-size: 28px; font-weight: 800; }
+.heatmap-card .bar { height: 8px; border-radius: 4px; margin-top: 10px; }
+
+/* Ideas */
+.ideas-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
+.idea-card {
+  background: var(--surface);
+  border: 2px solid var(--border);
+  border-radius: var(--radius);
+  padding: 20px;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.idea-card:hover { border-color: var(--primary); }
+.idea-card .idea-title { font-weight: 800; margin-bottom: 8px; font-size: 18px; }
+.idea-card .idea-desc { color: var(--text-secondary); font-size: 16px; }
+.idea-card .idea-meta { display: flex; gap: 8px; margin-top: 10px; }
+
+/* Modal */
+.modal {
+  display: none;
+  position: fixed;
+  top: 0; left: 0;
+  width: 100%; height: 100%;
+  background: rgba(0,0,0,0.5);
+  z-index: 1000;
+  overflow-y: auto;
+}
+.modal.open { display: flex; justify-content: center; padding: 40px 20px; }
+.modal-content {
+  background: white;
+  border: 2px solid var(--border);
+  border-radius: 12px;
+  padding: 28px;
+  max-width: 850px;
+  width: 100%;
+  position: relative;
+  max-height: 90vh;
+  overflow-y: auto;
+}
+.modal-close {
+  position: absolute;
+  top: 14px; right: 18px;
+  background: none;
+  border: none;
+  color: var(--text-muted);
+  font-size: 30px;
+  cursor: pointer;
+}
+.modal-close:hover { color: var(--text); }
+
+.detail-section { margin-bottom: 24px; }
+.detail-section h3 { color: var(--secondary); font-size: 18px; margin-bottom: 10px; border-bottom: 2px solid var(--border); padding-bottom: 6px; font-weight: 800; }
+.detail-row { display: flex; padding: 6px 0; font-size: 16px; }
+.detail-label { color: var(--text-muted); width: 180px; flex-shrink: 0; font-weight: 600; }
+.detail-value { color: var(--text); flex: 1; }
+
+/* Settings */
+.settings-form { max-width: 550px; }
+.setting-row { margin-bottom: 16px; }
+.setting-row label { display: block; color: var(--text-muted); font-size: 15px; margin-bottom: 6px; font-weight: 600; }
+.setting-row input { width: 100%; }
+
+@media (max-width: 768px) {
+  .candidate-card { grid-template-columns: 44px 1fr; }
+  .calendar-grid { grid-template-columns: repeat(2, 1fr); }
+  .stats-bar { flex-wrap: wrap; }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..b3e6dbe
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">S</text>
+</svg>
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..103e804
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,103 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>SDCC Story Engine</title>
+  <link rel="stylesheet" href="/css/styles.css">
+</head>
+<body>
+  <header>
+    <div class="header-left">
+      <h1>SDCC Story Engine</h1>
+      <span class="subtitle">Borrower Stories &rarr; Social Impact</span>
+    </div>
+    <div class="header-right">
+      <span id="ig-status" class="badge">Instagram: Checking...</span>
+      <button class="btn btn-sm" onclick="connectInstagram()">Connect IG</button>
+    </div>
+  </header>
+
+  <div class="stats-bar" id="stats-bar"></div>
+
+  <nav class="tabs">
+    <button class="tab active" data-tab="candidates" onclick="switchTab('candidates')">Candidates</button>
+    <button class="tab" data-tab="stories" onclick="switchTab('stories')">Stories</button>
+    <button class="tab" data-tab="calendar" onclick="switchTab('calendar')">Calendar</button>
+    <button class="tab" data-tab="heatmap" onclick="switchTab('heatmap')">Heat Map</button>
+    <button class="tab" data-tab="ideas" onclick="switchTab('ideas')">Content Ideas</button>
+    <button class="tab" data-tab="settings" onclick="switchTab('settings')">Settings</button>
+  </nav>
+
+  <!-- Candidates Tab -->
+  <div class="tab-content active" id="tab-candidates">
+    <div class="controls">
+      <div class="filter-group">
+        <label>Tier:</label>
+        <select id="filter-tier" onchange="loadCandidates()">
+          <option value="">All</option>
+          <option value="A">A — Press + Social + Lawmakers</option>
+          <option value="B">B — Social + Lawmakers</option>
+          <option value="C">C — Social Only</option>
+          <option value="D">D — Needs Enrichment</option>
+        </select>
+      </div>
+      <div class="filter-group">
+        <input type="text" id="filter-search" placeholder="Search name, zip, story..." onkeyup="debounceSearch()">
+      </div>
+      <button class="btn btn-action" onclick="loadCandidates()">Search</button>
+      <button class="btn btn-resource" onclick="refreshData()">Refresh from Sheet</button>
+    </div>
+    <div class="candidate-count" id="candidate-count"></div>
+    <div class="candidate-list" id="candidate-list"></div>
+    <div class="pagination" id="pagination"></div>
+  </div>
+
+  <!-- Stories Tab -->
+  <div class="tab-content" id="tab-stories">
+    <div class="controls">
+      <select id="story-filter" onchange="loadStories()">
+        <option value="">All Stories</option>
+        <option value="draft">Drafts</option>
+        <option value="scheduled">Scheduled</option>
+        <option value="posted">Posted</option>
+      </select>
+    </div>
+    <div class="story-list" id="story-list"></div>
+  </div>
+
+  <!-- Calendar Tab -->
+  <div class="tab-content" id="tab-calendar">
+    <div class="controls">
+      <button class="btn btn-action" onclick="autoFillCalendar()">Auto-Fill 7 Days</button>
+      <span class="hint">Picks top Tier A/B candidates, generates stories, schedules 2x daily (9am + 6pm PT)</span>
+    </div>
+    <div class="calendar-grid" id="calendar-grid"></div>
+  </div>
+
+  <!-- Heat Map Tab -->
+  <div class="tab-content" id="tab-heatmap">
+    <div class="heatmap-container" id="heatmap-container"></div>
+  </div>
+
+  <!-- Content Ideas Tab -->
+  <div class="tab-content" id="tab-ideas">
+    <div class="ideas-grid" id="ideas-grid"></div>
+  </div>
+
+  <!-- Settings Tab -->
+  <div class="tab-content" id="tab-settings">
+    <div class="settings-form" id="settings-form"></div>
+  </div>
+
+  <!-- Candidate Detail Modal -->
+  <div class="modal" id="candidate-modal">
+    <div class="modal-content">
+      <button class="modal-close" onclick="closeModal()">&times;</button>
+      <div id="candidate-detail"></div>
+    </div>
+  </div>
+
+  <script src="/js/app.js"></script>
+</body>
+</html>
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..b691aba
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,872 @@
+/**
+ * SDCC Story Engine — Frontend
+ * Safe DOM rendering (no raw innerHTML with user data)
+ */
+const AUTH = 'Basic ' + btoa('admin:DWSecure2024!');
+const headers = { 'Authorization': AUTH, 'Content-Type': 'application/json' };
+let currentPage = 1;
+let searchTimer = null;
+
+// ─── Safe text escaping ───
+function esc(s) {
+  if (s === null || s === undefined) return '';
+  const d = document.createElement('div');
+  d.textContent = String(s);
+  return d.textContent;
+}
+
+// Safe render: uses template element to parse trusted HTML with escaped dynamic values
+function render(el, html) {
+  const t = document.createElement('template');
+  t.setHTMLUnsafe(html);
+  el.replaceChildren(t.content.cloneNode(true));
+}
+
+// ─── API helper ───
+async function api(path, opts = {}) {
+  const res = await fetch(path, { headers, ...opts });
+  return res.json();
+}
+
+// ─── Tab switching ───
+function switchTab(name) {
+  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
+  document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
+  if (name === 'candidates') loadCandidates();
+  if (name === 'stories') loadStories();
+  if (name === 'calendar') loadCalendar();
+  if (name === 'heatmap') loadHeatmap();
+  if (name === 'ideas') loadIdeas();
+  if (name === 'settings') loadSettings();
+}
+
+// ─── Stats Bar ───
+async function loadStats() {
+  const s = await api('/api/stats');
+  const bar = document.getElementById('stats-bar');
+  const items = [
+    { label: 'Total', value: s.total, color: '' },
+    { label: 'Tier A', value: s.tierA, color: 'var(--lane-a)' },
+    { label: 'Tier B', value: s.tierB, color: 'var(--lane-b)' },
+    { label: 'Tier C', value: s.tierC, color: 'var(--lane-c)' },
+    { label: 'W/ Story', value: s.withStory, color: '' },
+    { label: 'Consent', value: s.withConsent, color: 'var(--success)' },
+    { label: 'Press Ready', value: s.pressReady, color: 'var(--warning)' },
+    { label: 'Stories Made', value: s.totalStories, color: '' },
+    { label: 'Scheduled', value: s.scheduled, color: 'var(--lane-b)' },
+    { label: 'Posted', value: s.posted, color: 'var(--success)' }
+  ];
+  bar.replaceChildren();
+  for (const item of items) {
+    const card = document.createElement('div');
+    card.className = 'stat-card';
+    const val = document.createElement('div');
+    val.className = 'stat-value';
+    val.textContent = item.value || 0;
+    if (item.color) val.style.color = item.color;
+    const lbl = document.createElement('div');
+    lbl.className = 'stat-label';
+    lbl.textContent = item.label;
+    card.append(val, lbl);
+    // Click tier stats to filter candidates
+    if (['Tier A', 'Tier B', 'Tier C'].includes(item.label)) {
+      card.style.cursor = 'pointer';
+      card.addEventListener('click', () => {
+        document.getElementById('filter-tier').value = item.label.split(' ')[1];
+        switchTab('candidates');
+      });
+    }
+    bar.appendChild(card);
+  }
+}
+
+// ─── Candidates List ───
+async function loadCandidates() {
+  const tier = document.getElementById('filter-tier').value;
+  const search = document.getElementById('filter-search').value;
+  const params = new URLSearchParams({ page: currentPage, limit: 50 });
+  if (tier) params.set('tier', tier);
+  if (search) params.set('search', search);
+
+  const data = await api('/api/candidates?' + params);
+  document.getElementById('candidate-count').textContent =
+    data.total + ' candidates' + (tier ? ' (Tier ' + tier + ')' : '') + (search ? ' matching "' + search + '"' : '');
+
+  const list = document.getElementById('candidate-list');
+  list.replaceChildren();
+
+  for (const c of data.candidates) {
+    const card = document.createElement('div');
+    card.className = 'candidate-card';
+    card.addEventListener('click', () => openCandidate(c.id));
+
+    // Score circle
+    const score = document.createElement('div');
+    score.className = 'score-circle';
+    score.textContent = c.candidate_score;
+    score.style.background = c.tier === 'A' ? 'var(--lane-a)' : c.tier === 'B' ? 'var(--lane-b)' : c.tier === 'C' ? 'var(--lane-c)' : 'var(--text-muted)';
+
+    // Name + tier
+    const nameEl = document.createElement('div');
+    const nameSpan = document.createElement('span');
+    nameSpan.className = 'candidate-name';
+    nameSpan.textContent = (c.first_name || 'Anonymous') + (c.last_name ? ' ' + c.last_name.charAt(0) + '.' : '');
+    const badge = document.createElement('span');
+    badge.className = 'badge badge-' + c.tier.toLowerCase();
+    badge.textContent = c.tier;
+    badge.style.marginLeft = '6px';
+    nameEl.append(nameSpan, badge);
+
+    // Amount
+    const amt = document.createElement('div');
+    amt.className = 'candidate-amount';
+    amt.textContent = c.amount_display || c.amount_owed_raw || '—';
+
+    // Snippet
+    const snippet = document.createElement('div');
+    snippet.className = 'candidate-snippet';
+    snippet.textContent = (c.emotional_impact || c.story || '').slice(0, 120);
+
+    // Location
+    const loc = document.createElement('div');
+    loc.className = 'candidate-meta';
+    loc.textContent = (c.zip_code || '') + (c.district_code ? ' | ' + c.district_code : '') + (c.representative_name ? ' (' + c.representative_name + ')' : '');
+
+    // Consent
+    const consent = document.createElement('div');
+    consent.className = 'candidate-meta';
+    const isConsent = (c.sharing_consent || '').toLowerCase().includes('yes');
+    consent.textContent = isConsent ? 'Consented' : 'No consent';
+    consent.style.color = isConsent ? 'var(--success)' : 'var(--text-muted)';
+
+    card.append(score, nameEl, amt, snippet, loc, consent);
+    list.appendChild(card);
+  }
+
+  // Pagination
+  const totalPages = Math.ceil(data.total / data.limit);
+  const pag = document.getElementById('pagination');
+  pag.replaceChildren();
+  for (let p = 1; p <= Math.min(totalPages, 20); p++) {
+    const btn = document.createElement('button');
+    btn.textContent = p;
+    if (p === currentPage) btn.classList.add('active');
+    btn.addEventListener('click', () => { currentPage = p; loadCandidates(); });
+    pag.appendChild(btn);
+  }
+}
+
+function debounceSearch() {
+  clearTimeout(searchTimer);
+  searchTimer = setTimeout(() => { currentPage = 1; loadCandidates(); }, 300);
+}
+
+// ─── Candidate Detail Modal ───
+async function openCandidate(id) {
+  const data = await api('/api/candidates/' + id);
+  const c = data.candidate;
+  const fd = data.fullData || {};
+  const modal = document.getElementById('candidate-modal');
+  const detail = document.getElementById('candidate-detail');
+  detail.replaceChildren();
+
+  // Header
+  const h2 = document.createElement('h2');
+  h2.textContent = (c.first_name || 'Anonymous') + ' ' + (c.last_name || '');
+  h2.style.marginBottom = '4px';
+  const sub = document.createElement('div');
+  sub.style.cssText = 'display:flex;gap:8px;margin-bottom:16px;align-items:center';
+  const tierBadge = document.createElement('span');
+  tierBadge.className = 'badge badge-' + (c.tier || 'd').toLowerCase();
+  tierBadge.textContent = 'Tier ' + (c.tier || 'D') + ' — Score ' + (c.candidate_score || 0);
+  const amtSpan = document.createElement('span');
+  amtSpan.style.cssText = 'color:var(--secondary);font-weight:700;font-size:16px';
+  amtSpan.textContent = c.amount_display || c.amount_owed_raw || '—';
+  sub.append(tierBadge, amtSpan);
+  detail.append(h2, sub);
+
+  // Sections
+  const sections = [
+    { title: 'Demographics', rows: [
+      ['Age', c.age], ['ZIP Code', c.zip_code], ['Gender', c.gender],
+      ['Race/Ethnicity', c.race_ethnicity], ['Marital Status', c.marital_status],
+      ['Household Size', c.household_size], ['Employment', c.employment],
+      ['Education', c.education], ['Income Type', c.income_type],
+      ['Income Range', c.income_range], ['Voter', c.is_voter]
+    ]},
+    { title: 'Loan Details', rows: [
+      ['Amount Owed (Raw)', c.amount_owed_raw], ['Amount Display', c.amount_display],
+      ['Amount Low', c.amount_low], ['Amount High', c.amount_high],
+      ['Loan Types', c.loan_types_raw], ['Repayment Plan', c.repayment_plan],
+      ['Monthly Payment', c.monthly_payment], ['First Loan Year', c.first_loan_year]
+    ]},
+    { title: 'Story Essentials (P/Q/R)', rows: [
+      ['Emotional Impact (P)', c.emotional_impact],
+      ['Can\'t Afford (Q)', c.affordability_impacts_raw],
+      ['Would Spend On (R)', c.alt_spending]
+    ]},
+    { title: 'Story Content', rows: [
+      ['Their Story', c.story], ['Key Quote', c.key_quote],
+      ['Story Category', c.story_category], ['Story Strength', c.story_strength]
+    ]},
+    { title: 'Consent & Civic', rows: [
+      ['Sharing Consent', c.sharing_consent], ['Press Ready', c.press_ready],
+      ['Registered Voter', c.is_voter], ['Platform Fit', c.platform_fit]
+    ]},
+    { title: 'Congressional District', rows: [
+      ['District', c.district_code], ['Representative', c.representative_name],
+      ['Party', c.representative_party]
+    ]}
+  ];
+
+  for (const section of sections) {
+    const sec = document.createElement('div');
+    sec.className = 'detail-section';
+    const h3 = document.createElement('h3');
+    h3.textContent = section.title;
+    sec.appendChild(h3);
+    for (const [label, value] of section.rows) {
+      if (!value && value !== 0) continue;
+      const row = document.createElement('div');
+      row.className = 'detail-row';
+      const lbl = document.createElement('div');
+      lbl.className = 'detail-label';
+      lbl.textContent = label;
+      const val = document.createElement('div');
+      val.className = 'detail-value';
+      val.textContent = String(value);
+      // Highlight long text fields
+      if (String(value).length > 100) {
+        val.style.cssText = 'font-size:12px;line-height:1.5;white-space:pre-wrap';
+      }
+      row.append(lbl, val);
+      sec.appendChild(row);
+    }
+    detail.appendChild(sec);
+  }
+
+  // Key quote highlight
+  if (c.key_quote) {
+    const quoteBox = document.createElement('div');
+    quoteBox.style.cssText = 'font-style:italic;color:var(--text-secondary);padding:12px;border-left:3px solid var(--lane-a);margin:12px 0;font-size:15px';
+    quoteBox.textContent = '"' + c.key_quote + '"';
+    detail.insertBefore(quoteBox, detail.children[2]);
+  }
+
+  // Action buttons
+  const actions = document.createElement('div');
+  actions.style.cssText = 'display:flex;gap:8px;margin-top:16px;flex-wrap:wrap';
+
+  const genBtn = document.createElement('button');
+  genBtn.className = 'btn btn-action';
+  genBtn.textContent = 'Generate Story';
+  genBtn.addEventListener('click', () => generateStoryForCandidate(c.id));
+
+  actions.appendChild(genBtn);
+
+  // Show existing stories
+  if (data.stories && data.stories.length > 0) {
+    const storyHeader = document.createElement('h3');
+    storyHeader.textContent = 'Generated Stories (' + data.stories.length + ')';
+    storyHeader.style.cssText = 'color:var(--secondary);margin-top:16px;border-bottom:1px solid var(--border);padding-bottom:4px';
+    detail.appendChild(storyHeader);
+
+    for (const st of data.stories) {
+      const stCard = document.createElement('div');
+      stCard.className = 'story-card';
+      stCard.style.marginTop = '8px';
+
+      const oneLiner = document.createElement('div');
+      oneLiner.style.cssText = 'font-weight:700;font-size:14px;margin-bottom:6px';
+      oneLiner.textContent = st.story_one_liner || '';
+
+      const igCap = document.createElement('div');
+      igCap.style.cssText = 'font-size:12px;color:var(--text-secondary);white-space:pre-wrap;margin-bottom:6px';
+      igCap.textContent = st.instagram_caption || '';
+
+      const xPost = document.createElement('div');
+      xPost.style.cssText = 'font-size:12px;color:var(--lane-b);margin-bottom:6px';
+      xPost.textContent = 'X: ' + (st.x_post || '');
+
+      const statusBadge = document.createElement('span');
+      statusBadge.className = 'badge badge-' + (st.status || 'draft');
+      statusBadge.textContent = st.status || 'draft';
+
+      const stActions = document.createElement('div');
+      stActions.style.cssText = 'display:flex;gap:6px;margin-top:8px';
+
+      const gfxBtn = document.createElement('button');
+      gfxBtn.className = 'btn btn-sm btn-action';
+      gfxBtn.textContent = 'Generate Graphic';
+      gfxBtn.addEventListener('click', (e) => { e.stopPropagation(); generateGraphicForStory(st.id); });
+
+      const lwBtn = document.createElement('button');
+      lwBtn.className = 'btn btn-sm btn-policy';
+      lwBtn.textContent = 'Lawmaker Graphic';
+      lwBtn.addEventListener('click', (e) => { e.stopPropagation(); generateLawmakerGraphicForStory(st.id); });
+
+      stActions.append(gfxBtn, lwBtn);
+
+      if (st.image_path) {
+        const img = document.createElement('img');
+        img.src = st.image_path;
+        img.className = 'story-image';
+        img.style.maxWidth = '300px';
+        stCard.append(oneLiner, igCap, xPost, statusBadge, stActions, img);
+      } else {
+        stCard.append(oneLiner, igCap, xPost, statusBadge, stActions);
+      }
+      detail.appendChild(stCard);
+    }
+  }
+
+  detail.appendChild(actions);
+  modal.classList.add('open');
+}
+
+function closeModal() {
+  document.getElementById('candidate-modal').classList.remove('open');
+}
+
+// Close modal on backdrop click
+document.getElementById('candidate-modal').addEventListener('click', (e) => {
+  if (e.target === e.currentTarget) closeModal();
+});
+
+// ─── Story Generation ───
+async function generateStoryForCandidate(candidateId) {
+  const btn = event.target;
+  btn.textContent = 'Generating...';
+  btn.disabled = true;
+  try {
+    const result = await api('/api/stories/generate', { method: 'POST', body: JSON.stringify({ candidate_id: candidateId }) });
+    if (result.id) {
+      openCandidate(candidateId); // Refresh modal
+    } else {
+      alert('Generation failed: ' + (result.error || 'Unknown error'));
+    }
+  } catch (e) {
+    alert('Error: ' + e.message);
+  } finally {
+    btn.textContent = 'Generate Story';
+    btn.disabled = false;
+  }
+}
+
+async function generateGraphicForStory(storyId) {
+  const result = await api('/api/stories/' + storyId + '/generate-graphic', { method: 'POST', body: '{}' });
+  if (result.success) {
+    loadStories();
+    // Refresh the current modal if open
+    const detail = document.getElementById('candidate-detail');
+    if (detail.children.length > 0) {
+      // Re-open the same candidate
+    }
+  }
+}
+
+async function generateLawmakerGraphicForStory(storyId) {
+  const result = await api('/api/stories/' + storyId + '/generate-lawmaker-graphic', { method: 'POST', body: '{}' });
+  if (result.success) {
+    loadStories();
+  }
+}
+
+// ─── Stories Tab (expandable) ───
+async function loadStories() {
+  const filter = document.getElementById('story-filter').value;
+  const params = filter ? '?status=' + filter : '';
+  const stories = await api('/api/stories' + params);
+  const list = document.getElementById('story-list');
+  list.replaceChildren();
+
+  if (stories.length === 0) {
+    const empty = document.createElement('div');
+    empty.style.cssText = 'color:var(--text-muted);text-align:center;padding:40px';
+    empty.textContent = 'No stories yet. Go to Candidates tab and generate stories for borrowers who consented to social media sharing.';
+    list.appendChild(empty);
+    return;
+  }
+
+  for (const s of stories) {
+    const card = document.createElement('div');
+    card.className = 'story-card';
+    card.style.cursor = 'pointer';
+
+    // Header row
+    const header = document.createElement('div');
+    header.className = 'story-header';
+
+    const title = document.createElement('div');
+    const nameSpan = document.createElement('strong');
+    nameSpan.textContent = (s.first_name || 'Unknown') + (s.last_name ? ' ' + s.last_name.charAt(0) + '.' : '');
+    const amtSpan = document.createElement('span');
+    amtSpan.style.cssText = 'color:var(--secondary);margin-left:8px';
+    amtSpan.textContent = s.amount_owed_raw || '';
+    const tierBdg = document.createElement('span');
+    tierBdg.className = 'badge badge-' + (s.tier || 'd').toLowerCase();
+    tierBdg.textContent = s.tier || '?';
+    tierBdg.style.marginLeft = '8px';
+    title.append(nameSpan, amtSpan, tierBdg);
+
+    const statusBdg = document.createElement('span');
+    statusBdg.className = 'badge badge-' + (s.status || 'draft');
+    statusBdg.textContent = s.status || 'draft';
+    header.append(title, statusBdg);
+
+    // One-liner (always visible)
+    const oneLiner = document.createElement('div');
+    oneLiner.style.cssText = 'font-size:13px;color:var(--text-secondary);margin-top:6px';
+    oneLiner.textContent = s.story_one_liner || '(no story text)';
+
+    // Expandable detail section
+    const detail = document.createElement('div');
+    detail.style.cssText = 'display:none;margin-top:12px;border-top:1px solid var(--border);padding-top:12px';
+
+    // Borrower details
+    const borrowerSection = document.createElement('div');
+    borrowerSection.style.marginBottom = '12px';
+    const bTitle = document.createElement('h4');
+    bTitle.textContent = 'Borrower Details';
+    bTitle.style.cssText = 'color:var(--secondary);font-size:13px;margin-bottom:6px';
+    borrowerSection.appendChild(bTitle);
+
+    const details = [
+      ['Name', (s.first_name || '') + ' ' + (s.last_name || '')],
+      ['ZIP', s.zip_code || ''],
+      ['Amount', s.amount_owed_raw || ''],
+      ['District', s.district_code || ''],
+      ['Representative', s.representative_name || ''],
+      ['Emotional Impact', s.emotional_impact || ''],
+      ['Can\'t Afford', s.affordability_impacts_raw || ''],
+      ['Would Spend On', s.alt_spending || '']
+    ];
+    for (const [lbl, val] of details) {
+      if (!val) continue;
+      const row = document.createElement('div');
+      row.className = 'detail-row';
+      const l = document.createElement('div');
+      l.className = 'detail-label';
+      l.textContent = lbl;
+      const v = document.createElement('div');
+      v.className = 'detail-value';
+      v.textContent = val;
+      if (val.length > 80) v.style.cssText = 'font-size:12px;white-space:pre-wrap';
+      row.append(l, v);
+      borrowerSection.appendChild(row);
+    }
+    detail.appendChild(borrowerSection);
+
+    // Instagram caption
+    if (s.instagram_caption) {
+      const igSec = document.createElement('div');
+      igSec.style.marginBottom = '8px';
+      const igLabel = document.createElement('div');
+      igLabel.style.cssText = 'font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px';
+      igLabel.textContent = 'Instagram Caption';
+      const igText = document.createElement('div');
+      igText.style.cssText = 'font-size:12px;color:var(--text-secondary);white-space:pre-wrap';
+      igText.textContent = s.instagram_caption;
+      igSec.append(igLabel, igText);
+      detail.appendChild(igSec);
+    }
+
+    // X post
+    if (s.x_post) {
+      const xSec = document.createElement('div');
+      xSec.style.marginBottom = '8px';
+      const xLabel = document.createElement('div');
+      xLabel.style.cssText = 'font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px';
+      xLabel.textContent = 'X Post';
+      const xText = document.createElement('div');
+      xText.style.cssText = 'font-size:12px;color:var(--lane-b)';
+      xText.textContent = s.x_post;
+      xSec.append(xLabel, xText);
+      detail.appendChild(xSec);
+    }
+
+    // Image
+    if (s.image_path) {
+      const img = document.createElement('img');
+      img.src = s.image_path;
+      img.className = 'story-image';
+      img.style.maxWidth = '300px';
+      detail.appendChild(img);
+    }
+
+    // Action buttons
+    const actions = document.createElement('div');
+    actions.className = 'story-actions';
+    const gfxBtn = document.createElement('button');
+    gfxBtn.className = 'btn btn-sm btn-action';
+    gfxBtn.textContent = 'Generate Graphic';
+    gfxBtn.addEventListener('click', (e) => { e.stopPropagation(); generateGraphicForStory(s.id); });
+    const lwBtn = document.createElement('button');
+    lwBtn.className = 'btn btn-sm btn-policy';
+    lwBtn.textContent = 'Lawmaker Graphic';
+    lwBtn.addEventListener('click', (e) => { e.stopPropagation(); generateLawmakerGraphicForStory(s.id); });
+    const pubBtn = document.createElement('button');
+    pubBtn.className = 'btn btn-sm btn-resource';
+    pubBtn.textContent = 'Publish to IG';
+    pubBtn.addEventListener('click', (e) => { e.stopPropagation(); publishStory(s.id); });
+    actions.append(gfxBtn, lwBtn, pubBtn);
+    detail.appendChild(actions);
+
+    // Toggle expand
+    card.addEventListener('click', () => {
+      const open = detail.style.display !== 'none';
+      detail.style.display = open ? 'none' : 'block';
+    });
+
+    card.append(header, oneLiner, detail);
+    list.appendChild(card);
+  }
+}
+
+async function publishStory(id) {
+  if (!confirm('Publish this story to Instagram?')) return;
+  const result = await api('/api/stories/' + id + '/publish', { method: 'POST', body: '{}' });
+  if (result.success) {
+    loadStories();
+  } else {
+    alert('Publish failed: ' + (result.error || 'Unknown'));
+  }
+}
+
+// ─── Calendar ───
+async function loadCalendar() {
+  const stories = await api('/api/calendar');
+  const grid = document.getElementById('calendar-grid');
+  grid.replaceChildren();
+
+  // Day headers
+  const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+  for (const d of days) {
+    const hdr = document.createElement('div');
+    hdr.style.cssText = 'text-align:center;font-weight:700;color:var(--text-muted);font-size:12px;padding:4px';
+    hdr.textContent = d;
+    grid.appendChild(hdr);
+  }
+
+  // Group stories by date
+  const byDate = {};
+  for (const s of stories) {
+    const date = (s.scheduled_at || '').split('T')[0];
+    if (!byDate[date]) byDate[date] = [];
+    byDate[date].push(s);
+  }
+
+  // Generate 28-day grid starting from today
+  const today = new Date();
+  const startDay = new Date(today);
+  startDay.setDate(today.getDate() - today.getDay()); // Start on Sunday
+
+  for (let i = 0; i < 28; i++) {
+    const d = new Date(startDay);
+    d.setDate(startDay.getDate() + i);
+    const dateStr = d.toISOString().split('T')[0];
+
+    const cell = document.createElement('div');
+    cell.className = 'calendar-day';
+    if (dateStr === today.toISOString().split('T')[0]) cell.style.borderColor = 'var(--primary)';
+
+    const dayHeader = document.createElement('div');
+    dayHeader.className = 'day-header';
+    dayHeader.textContent = d.getDate() + ' ' + d.toLocaleDateString('en-US', { month: 'short' });
+    cell.appendChild(dayHeader);
+
+    const dayStories = byDate[dateStr] || [];
+    for (const s of dayStories) {
+      const slot = document.createElement('div');
+      slot.className = 'calendar-slot ' + (s.platform || 'instagram');
+      const time = (s.scheduled_at || '').includes('09:') ? '9am' : '6pm';
+      slot.textContent = time + ' ' + (s.first_name || '?') + ' (' + (s.platform || 'ig') + ')';
+      slot.style.cursor = 'pointer';
+      slot.addEventListener('click', () => {
+        if (s.candidate_id) openCandidate(s.candidate_id);
+      });
+      cell.appendChild(slot);
+    }
+    grid.appendChild(cell);
+  }
+}
+
+async function autoFillCalendar() {
+  if (!confirm('Auto-fill 7 days with Tier A/B candidate stories (2x daily)?')) return;
+  const btn = event.target;
+  btn.textContent = 'Filling...';
+  btn.disabled = true;
+  try {
+    const result = await api('/api/calendar/auto-fill', { method: 'POST', body: JSON.stringify({ days: 7 }) });
+    alert('Scheduled ' + (result.scheduled || 0) + ' stories');
+    loadCalendar();
+  } catch (e) {
+    alert('Error: ' + e.message);
+  } finally {
+    btn.textContent = 'Auto-Fill 7 Days';
+    btn.disabled = false;
+  }
+}
+
+// ─── Heat Map ───
+async function loadHeatmap() {
+  const data = await api('/api/heatmap');
+  const container = document.getElementById('heatmap-container');
+  container.replaceChildren();
+
+  // Story Classification Tree — clickable branches
+  const treeSection = document.createElement('div');
+  treeSection.style.cssText = 'width:100%;margin-bottom:20px;grid-column:1/-1';
+  const treeTitle = document.createElement('h3');
+  treeTitle.textContent = 'Story Classification';
+  treeTitle.style.cssText = 'color:var(--secondary);margin-bottom:12px';
+  treeSection.appendChild(treeTitle);
+
+  const tiers = data.byTier || [];
+  const tierColors = { A: 'var(--lane-a)', B: 'var(--lane-b)', C: 'var(--lane-c)', D: 'var(--text-muted)' };
+  const tierLabels = { A: 'Press + Social + Lawmakers', B: 'Social + Lawmakers', C: 'Social Only', D: 'Needs Enrichment' };
+  const tierTotal = tiers.reduce((sum, t) => sum + t.count, 0) || 1;
+
+  for (const t of tiers) {
+    const branch = document.createElement('div');
+    branch.style.cssText = 'display:flex;align-items:center;gap:12px;padding:8px 12px;margin-bottom:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:all 0.15s';
+    branch.addEventListener('mouseenter', () => { branch.style.borderColor = tierColors[t.tier] || 'var(--border)'; });
+    branch.addEventListener('mouseleave', () => { branch.style.borderColor = 'var(--border)'; });
+    // Click to filter candidates by this tier
+    branch.addEventListener('click', () => {
+      document.getElementById('filter-tier').value = t.tier;
+      currentPage = 1;
+      switchTab('candidates');
+    });
+
+    const badge = document.createElement('span');
+    badge.className = 'badge badge-' + (t.tier || 'd').toLowerCase();
+    badge.textContent = 'Tier ' + t.tier;
+    badge.style.minWidth = '50px';
+
+    const label = document.createElement('span');
+    label.style.cssText = 'color:var(--text-secondary);font-size:13px;flex:1';
+    label.textContent = tierLabels[t.tier] || '';
+
+    const count = document.createElement('span');
+    count.style.cssText = 'font-weight:700;font-size:16px;min-width:60px;text-align:right';
+    count.textContent = t.count;
+
+    const barOuter = document.createElement('div');
+    barOuter.style.cssText = 'flex:2;height:8px;background:var(--surface-el);border-radius:4px;overflow:hidden';
+    const barInner = document.createElement('div');
+    barInner.style.cssText = 'height:100%;border-radius:4px;background:' + (tierColors[t.tier] || 'var(--text-muted)');
+    barInner.style.width = Math.round((t.count / tierTotal) * 100) + '%';
+    barOuter.appendChild(barInner);
+
+    branch.append(badge, label, count, barOuter);
+    treeSection.appendChild(branch);
+  }
+  container.appendChild(treeSection);
+
+  // State breakdown
+  if (data.byState && data.byState.length > 0) {
+    const stateTitle = document.createElement('h3');
+    stateTitle.textContent = 'By State';
+    stateTitle.style.cssText = 'color:var(--secondary);margin-bottom:8px;grid-column:1/-1';
+    container.appendChild(stateTitle);
+
+    const maxCount = Math.max(...data.byState.map(s => s.count));
+    for (const s of data.byState.slice(0, 30)) {
+      const card = document.createElement('div');
+      card.className = 'heatmap-card';
+
+      const state = document.createElement('div');
+      state.className = 'district';
+      state.textContent = s.state;
+
+      const cnt = document.createElement('div');
+      cnt.className = 'count';
+      cnt.textContent = s.count;
+
+      const avgScore = document.createElement('div');
+      avgScore.style.cssText = 'font-size:11px;color:var(--text-muted)';
+      avgScore.textContent = 'Avg score: ' + Math.round(s.avg_score || 0);
+
+      const bar = document.createElement('div');
+      bar.className = 'bar';
+      bar.style.background = 'linear-gradient(90deg, var(--lane-a) 0%, var(--lane-b) 100%)';
+      bar.style.width = Math.round((s.count / maxCount) * 100) + '%';
+
+      card.append(state, cnt, avgScore, bar);
+      container.appendChild(card);
+    }
+  }
+
+  // District breakdown
+  if (data.byDistrict && data.byDistrict.length > 0) {
+    const distTitle = document.createElement('h3');
+    distTitle.textContent = 'By Congressional District';
+    distTitle.style.cssText = 'color:var(--secondary);margin-bottom:8px;grid-column:1/-1;margin-top:16px';
+    container.appendChild(distTitle);
+
+    for (const d of data.byDistrict.slice(0, 20)) {
+      const card = document.createElement('div');
+      card.className = 'heatmap-card';
+      card.style.cursor = 'pointer';
+      card.addEventListener('click', () => {
+        document.getElementById('filter-search').value = d.district_code;
+        document.getElementById('filter-tier').value = '';
+        currentPage = 1;
+        switchTab('candidates');
+      });
+
+      const dist = document.createElement('div');
+      dist.className = 'district';
+      dist.textContent = d.district_code;
+
+      const rep = document.createElement('div');
+      rep.className = 'rep-name';
+      rep.textContent = (d.representative_name || '—') + ' (' + (d.representative_party || '?') + ')';
+
+      const cnt = document.createElement('div');
+      cnt.className = 'count';
+      cnt.textContent = d.count;
+
+      const tierBdg = document.createElement('span');
+      tierBdg.className = 'badge badge-' + (d.tier || 'd').toLowerCase();
+      tierBdg.textContent = 'Tier ' + (d.tier || '?');
+
+      card.append(dist, rep, cnt, tierBdg);
+      container.appendChild(card);
+    }
+  }
+}
+
+// ─── Content Ideas ───
+async function loadIdeas() {
+  const ideas = await api('/api/ideas');
+  const grid = document.getElementById('ideas-grid');
+  grid.replaceChildren();
+
+  const laneColors = { action: 'var(--lane-a)', policy: 'var(--lane-b)', resources: 'var(--lane-c)' };
+  for (const idea of ideas) {
+    const card = document.createElement('div');
+    card.className = 'idea-card';
+
+    const title = document.createElement('div');
+    title.className = 'idea-title';
+    title.textContent = idea.title;
+
+    const desc = document.createElement('div');
+    desc.className = 'idea-desc';
+    desc.textContent = idea.description;
+
+    const meta = document.createElement('div');
+    meta.className = 'idea-meta';
+    const laneBdg = document.createElement('span');
+    laneBdg.className = 'badge';
+    laneBdg.textContent = idea.lane;
+    laneBdg.style.background = laneColors[idea.lane] || 'var(--text-muted)';
+    laneBdg.style.color = 'white';
+    const platBdg = document.createElement('span');
+    platBdg.className = 'badge badge-draft';
+    platBdg.textContent = idea.platform;
+    meta.append(laneBdg, platBdg);
+
+    card.append(title, desc, meta);
+    grid.appendChild(card);
+  }
+}
+
+// ─── Settings ───
+async function loadSettings() {
+  const settings = await api('/api/settings');
+  const form = document.getElementById('settings-form');
+  form.replaceChildren();
+
+  const title = document.createElement('h3');
+  title.textContent = 'Instagram API Settings';
+  title.style.cssText = 'color:var(--secondary);margin-bottom:16px';
+  form.appendChild(title);
+
+  const fields = [
+    { key: 'ig_app_id', label: 'Meta App ID', type: 'text' },
+    { key: 'ig_app_secret', label: 'App Secret', type: 'password' },
+    { key: 'ig_access_token', label: 'Access Token (auto-filled on connect)', type: 'text' },
+    { key: 'ig_business_account_id', label: 'IG Business Account ID', type: 'text' },
+    { key: 'app_url', label: 'App URL (for OAuth redirect)', type: 'text' },
+    { key: 'post_time_morning', label: 'Morning Post Time', type: 'text' },
+    { key: 'post_time_evening', label: 'Evening Post Time', type: 'text' },
+    { key: 'auto_schedule', label: 'Auto-Post Scheduled Stories', type: 'text' }
+  ];
+
+  const inputs = {};
+  for (const f of fields) {
+    const row = document.createElement('div');
+    row.className = 'setting-row';
+    const label = document.createElement('label');
+    label.textContent = f.label;
+    const input = document.createElement('input');
+    input.type = f.type;
+    input.value = settings[f.key] || '';
+    input.placeholder = f.label;
+    inputs[f.key] = input;
+    row.append(label, input);
+    form.appendChild(row);
+  }
+
+  const saveBtn = document.createElement('button');
+  saveBtn.className = 'btn';
+  saveBtn.textContent = 'Save Settings';
+  saveBtn.style.marginTop = '12px';
+  saveBtn.addEventListener('click', async () => {
+    const body = {};
+    for (const [key, input] of Object.entries(inputs)) {
+      body[key] = input.value;
+    }
+    await api('/api/settings', { method: 'PUT', body: JSON.stringify(body) });
+    alert('Settings saved');
+  });
+  form.appendChild(saveBtn);
+}
+
+// ─── Instagram Connection ───
+function connectInstagram() {
+  window.location.href = '/auth/instagram';
+}
+
+async function checkIGStatus() {
+  try {
+    const status = await api('/api/instagram/status');
+    const el = document.getElementById('ig-status');
+    if (status.connected) {
+      el.textContent = 'Instagram: Connected';
+      el.className = 'badge badge-posted';
+    } else {
+      el.textContent = 'Instagram: Not Connected';
+      el.className = 'badge badge-draft';
+    }
+  } catch (e) { /* ignore */ }
+}
+
+// ─── Refresh data from Google Sheet ───
+async function refreshData() {
+  if (!confirm('Re-download data from Google Sheet and re-import all candidates?')) return;
+  const btn = event.target;
+  btn.textContent = 'Refreshing...';
+  btn.disabled = true;
+  try {
+    const result = await api('/api/import/refresh', { method: 'POST', body: '{}' });
+    alert('Imported ' + (result.imported || 0) + ' candidates');
+    loadStats();
+    loadCandidates();
+  } catch (e) {
+    alert('Error: ' + e.message);
+  } finally {
+    btn.textContent = 'Refresh from Sheet';
+    btn.disabled = false;
+  }
+}
+
+// ─── Init ───
+document.addEventListener('DOMContentLoaded', () => {
+  loadStats();
+  loadCandidates();
+  checkIGStatus();
+});
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..bd98f53
--- /dev/null
+++ b/server.js
@@ -0,0 +1,563 @@
+/**
+ * SDCC Story Engine — Port 7410
+ * Borrower stories → Gemini AI → Social media graphics → Instagram/X scheduling
+ * Auth: admin / DWSecure2024!
+ */
+const express = require('express');
+const helmet = require('helmet');
+const path = require('path');
+const fs = require('fs');
+const cron = require('node-cron');
+const Database = require('better-sqlite3');
+const { importData, downloadFreshData } = require('./lib/data-import');
+const { generateStory, generateGraphic, generateLawmakerGraphic, COLORS } = require('./lib/gemini');
+const { exchangeCodeForToken, getLongLivedToken, getIGBusinessAccount, publishToInstagram } = require('./lib/instagram-api');
+
+const PORT = process.env.PORT || 7410;
+const AUTH_USER = 'admin';
+const AUTH_PASS = 'DWSecure2024!';
+
+// ─── Database Setup ───
+const db = new Database(path.join(__dirname, 'db', 'stories.db'));
+db.pragma('journal_mode = WAL');
+
+db.exec(`
+  CREATE TABLE IF NOT EXISTS candidates (
+    id INTEGER PRIMARY KEY,
+    first_name TEXT,
+    last_name TEXT,
+    email TEXT,
+    zip_code TEXT,
+    age TEXT,
+    amount_owed_raw TEXT,
+    amount_low INTEGER DEFAULT 0,
+    amount_high INTEGER DEFAULT 0,
+    amount_display TEXT,
+    income_type TEXT,
+    income_range TEXT,
+    income_amount_low INTEGER DEFAULT 0,
+    income_amount_high INTEGER DEFAULT 0,
+    emotional_impact TEXT,
+    affordability_impacts_raw TEXT,
+    affordability_count INTEGER DEFAULT 0,
+    alt_spending TEXT,
+    story TEXT,
+    key_quote TEXT,
+    sharing_consent TEXT,
+    press_ready TEXT,
+    is_voter TEXT,
+    employment TEXT,
+    education TEXT,
+    race_ethnicity TEXT,
+    gender TEXT,
+    marital_status TEXT,
+    household_size TEXT,
+    loan_types_raw TEXT,
+    repayment_plan TEXT,
+    monthly_payment TEXT,
+    first_loan_year TEXT,
+    story_type TEXT,
+    platform_fit TEXT,
+    story_strength INTEGER DEFAULT 0,
+    story_category TEXT,
+    press_pitch_angle TEXT,
+    candidate_score INTEGER DEFAULT 0,
+    tier TEXT DEFAULT 'D',
+    district_code TEXT,
+    representative_name TEXT,
+    representative_party TEXT,
+    data_json TEXT,
+    created_at TEXT DEFAULT (datetime('now')),
+    updated_at TEXT DEFAULT (datetime('now'))
+  );
+
+  CREATE TABLE IF NOT EXISTS stories (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    candidate_id INTEGER REFERENCES candidates(id),
+    story_one_liner TEXT,
+    instagram_caption TEXT,
+    x_post TEXT,
+    hashtags TEXT,
+    story_category TEXT,
+    image_path TEXT,
+    lawmaker_image_path TEXT,
+    status TEXT DEFAULT 'draft',
+    scheduled_at TEXT,
+    posted_at TEXT,
+    ig_media_id TEXT,
+    platform TEXT DEFAULT 'instagram',
+    lane TEXT DEFAULT 'action',
+    created_at TEXT DEFAULT (datetime('now'))
+  );
+
+  CREATE TABLE IF NOT EXISTS settings (
+    key TEXT PRIMARY KEY,
+    value TEXT
+  );
+
+  CREATE TABLE IF NOT EXISTS schedule_log (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    story_id INTEGER,
+    platform TEXT,
+    status TEXT,
+    message TEXT,
+    created_at TEXT DEFAULT (datetime('now'))
+  );
+`);
+
+// Default settings
+const defaultSettings = {
+  ig_app_id: '', ig_app_secret: '', ig_access_token: '', ig_business_account_id: '',
+  app_url: 'http://45.61.58.125:' + PORT,
+  post_time_morning: '09:00', post_time_evening: '18:00',
+  auto_schedule: 'false'
+};
+for (const [key, value] of Object.entries(defaultSettings)) {
+  db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)').run(key, value);
+}
+
+function getSetting(key) { return db.prepare('SELECT value FROM settings WHERE key = ?').get(key)?.value || ''; }
+function setSetting(key, value) { db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); }
+
+// ─── ZIP to Congressional District (lightweight lookup) ───
+let districtLookup = {};
+try {
+  const { Pool } = require('pg');
+  const pool = new Pool({ connectionString: 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/sdcc', max: 2 });
+  pool.query('SELECT zip_codes, district_code, representative_name, representative_party FROM congressional_districts WHERE representative_name IS NOT NULL LIMIT 500')
+    .then(result => {
+      for (const row of result.rows) {
+        for (const zip of (row.zip_codes || [])) {
+          districtLookup[zip] = { district: row.district_code, name: row.representative_name, party: row.representative_party };
+        }
+      }
+      console.log('[StoryEngine] Loaded ' + Object.keys(districtLookup).length + ' ZIP-to-district mappings');
+    })
+    .catch(() => console.log('[StoryEngine] No congressional district data available'));
+} catch (e) {
+  console.log('[StoryEngine] pg not available — district lookup disabled');
+}
+
+function lookupDistrict(zip) {
+  if (!zip) return null;
+  return districtLookup[zip.toString().trim().slice(0, 5)] || null;
+}
+
+// ─── Data Import ───
+function loadCandidates() {
+  const data = importData();
+  const insert = db.prepare(
+    'INSERT OR REPLACE INTO candidates (id, first_name, last_name, email, zip_code, age,' +
+    'amount_owed_raw, amount_low, amount_high, amount_display,' +
+    'income_type, income_range, income_amount_low, income_amount_high,' +
+    'emotional_impact, affordability_impacts_raw, affordability_count, alt_spending,' +
+    'story, key_quote, sharing_consent, press_ready, is_voter,' +
+    'employment, education, race_ethnicity, gender, marital_status, household_size,' +
+    'loan_types_raw, repayment_plan, monthly_payment, first_loan_year,' +
+    'story_type, platform_fit, story_strength, story_category, press_pitch_angle,' +
+    'candidate_score, tier, district_code, representative_name, representative_party,' +
+    'data_json, updated_at)' +
+    'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,' +
+    '?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))'
+  );
+
+  const tx = db.transaction(() => {
+    for (const c of data) {
+      const district = lookupDistrict(c.zip_code);
+      insert.run(
+        c.id, c.first_name, c.last_name || '', c.email || '', c.zip_code, c.age,
+        c.amount_owed_raw, c.amount_low, c.amount_high, c.amount_display,
+        c.income_type, c.income_range, c.income_amount_low, c.income_amount_high,
+        c.emotional_impact, c.affordability_impacts_raw, c.affordability_count, c.alt_spending,
+        c.story, c.key_quote, c.sharing_consent, c.press_ready, c.is_voter,
+        c.employment, c.education, c.race_ethnicity, c.gender, c.marital_status, c.household_size,
+        c.loan_types_raw, c.repayment_plan, c.monthly_payment, c.first_loan_year,
+        c.story_type || '', c.platform_fit || '', c.story_strength || 0, c.story_category || '', c.press_pitch_angle || '',
+        c.candidate_score, c.tier,
+        district ? district.district : '', district ? district.name : '', district ? district.party : '',
+        JSON.stringify(c)
+      );
+    }
+  });
+  tx();
+  return data.length;
+}
+
+// ─── Preloaded Content Ideas ───
+const CONTENT_IDEAS = [
+  { title: 'Borrower Spotlight', description: 'Feature a Tier A borrower with emotional story + debt amount', lane: 'action', platform: 'both' },
+  { title: 'By The Numbers', description: 'Stat graphic: total debt, avg amount, % affected by affordability', lane: 'policy', platform: 'both' },
+  { title: 'What Would You Do?', description: 'Highlight what borrowers would spend loan payments on instead', lane: 'action', platform: 'instagram' },
+  { title: 'Lawmaker Alert', description: 'Tag a representative with a constituent story from their district', lane: 'policy', platform: 'x' },
+  { title: 'Story Thread', description: '5-post carousel of different borrower situations', lane: 'action', platform: 'instagram' },
+  { title: 'Resource Drop', description: 'Share repayment plan info, forgiveness options, or servicer tips', lane: 'resources', platform: 'both' },
+  { title: 'Emotional Impact', description: 'Focus on mental health + relationship impacts of student debt', lane: 'action', platform: 'both' },
+  { title: 'Voter Voice', description: 'Highlight borrowers who are voters and how debt affects their civic engagement', lane: 'policy', platform: 'x' },
+  { title: 'Affordability Crisis', description: 'What people CANNOT afford because of student loans', lane: 'action', platform: 'both' },
+  { title: 'Share Your Story CTA', description: 'Call to action encouraging others to submit their story', lane: 'resources', platform: 'both' },
+  { title: 'Generational Impact', description: 'Stories from different age groups showing long-term debt effects', lane: 'action', platform: 'instagram' },
+  { title: 'Default and Collections', description: 'Highlight borrowers facing default or collections actions', lane: 'action', platform: 'both' }
+];
+
+// ─── Express App ───
+const app = express();
+// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
+app.use(helmet({ contentSecurityPolicy: false }));
+app.use(express.json({ limit: '10mb' }));
+app.use(express.urlencoded({ extended: true }));
+app.use('/generated', express.static(path.join(__dirname, 'generated')));
+app.use(express.static(path.join(__dirname, 'public')));
+
+function requireAuth(req, res, next) {
+  if (req.path === '/health' || req.path === '/auth/instagram/callback') return next();
+  const auth = req.headers.authorization;
+  if (!auth || !auth.startsWith('Basic ')) {
+    res.setHeader('WWW-Authenticate', 'Basic realm="SDCC Story Engine"');
+    return res.status(401).json({ error: 'Auth required' });
+  }
+  const decoded = Buffer.from(auth.split(' ')[1], 'base64').toString();
+  const [user, pass] = decoded.split(':');
+  if (user === AUTH_USER && pass === AUTH_PASS) return next();
+  res.status(401).json({ error: 'Invalid credentials' });
+}
+
+app.get('/health', (req, res) => {
+  const candidateCount = db.prepare('SELECT COUNT(*) as c FROM candidates').get().c;
+  const storyCount = db.prepare('SELECT COUNT(*) as c FROM stories').get().c;
+  res.json({ status: 'ok', agent: 'SDCC Story Engine', port: PORT, candidates: candidateCount, stories: storyCount });
+});
+
+app.use(requireAuth);
+
+// ─── Candidates API ───
+app.get('/api/candidates', (req, res) => {
+  const tier = req.query.tier;
+  const page = parseInt(req.query.page) || 1;
+  const limit = parseInt(req.query.limit) || 50;
+  const search = req.query.search;
+  const sort = req.query.sort || 'candidate_score';
+  const order = req.query.order || 'desc';
+  const offset = (page - 1) * limit;
+  let where = '1=1';
+  const params = [];
+
+  if (tier) { where += ' AND tier = ?'; params.push(tier); }
+  if (search) { where += ' AND (first_name LIKE ? OR zip_code LIKE ? OR emotional_impact LIKE ?)'; params.push('%' + search + '%', '%' + search + '%', '%' + search + '%'); }
+
+  const allowedSort = ['candidate_score', 'amount_high', 'affordability_count', 'first_name', 'tier', 'zip_code'];
+  const sortCol = allowedSort.includes(sort) ? sort : 'candidate_score';
+  const sortOrder = order === 'asc' ? 'ASC' : 'DESC';
+
+  const total = db.prepare('SELECT COUNT(*) as c FROM candidates WHERE ' + where).get(...params).c;
+  const rows = db.prepare('SELECT id, first_name, last_name, zip_code, age, amount_owed_raw, amount_display,' +
+    'emotional_impact, affordability_impacts_raw, affordability_count, alt_spending,' +
+    'candidate_score, tier, sharing_consent, press_ready, is_voter,' +
+    'district_code, representative_name, representative_party,' +
+    'income_type, income_range, story, key_quote' +
+    ' FROM candidates WHERE ' + where + ' ORDER BY ' + sortCol + ' ' + sortOrder + ' LIMIT ? OFFSET ?')
+    .all(...params, limit, offset);
+
+  res.json({ total, page, limit, candidates: rows });
+});
+
+app.get('/api/candidates/:id', (req, res) => {
+  const row = db.prepare('SELECT * FROM candidates WHERE id = ?').get(req.params.id);
+  if (!row) return res.status(404).json({ error: 'Not found' });
+  let fullData = {};
+  try { fullData = JSON.parse(row.data_json || '{}'); } catch (e) { /* ignore */ }
+  const stories = db.prepare('SELECT * FROM stories WHERE candidate_id = ? ORDER BY created_at DESC').all(row.id);
+  res.json({ candidate: row, fullData, stories });
+});
+
+app.get('/api/heatmap', (req, res) => {
+  const byTier = db.prepare('SELECT tier, COUNT(*) as count FROM candidates GROUP BY tier').all();
+  const byDistrict = db.prepare('SELECT district_code, representative_name, representative_party, tier, COUNT(*) as count FROM candidates WHERE district_code != \'\' GROUP BY district_code, tier ORDER BY count DESC').all();
+  const byState = db.prepare('SELECT SUBSTR(district_code, 1, 2) as state, COUNT(*) as count, AVG(candidate_score) as avg_score FROM candidates WHERE district_code != \'\' GROUP BY state ORDER BY count DESC').all();
+  res.json({ byTier, byDistrict, byState });
+});
+
+app.get('/api/stats', (req, res) => {
+  const total = db.prepare('SELECT COUNT(*) as c FROM candidates').get().c;
+  const tierA = db.prepare('SELECT COUNT(*) as c FROM candidates WHERE tier = \'A\'').get().c;
+  const tierB = db.prepare('SELECT COUNT(*) as c FROM candidates WHERE tier = \'B\'').get().c;
+  const tierC = db.prepare('SELECT COUNT(*) as c FROM candidates WHERE tier = \'C\'').get().c;
+  const withStory = db.prepare('SELECT COUNT(*) as c FROM candidates WHERE story != \'\'').get().c;
+  const withConsent = db.prepare('SELECT COUNT(*) as c FROM candidates WHERE sharing_consent LIKE \'%yes%\' OR sharing_consent LIKE \'%Yes%\'').get().c;
+  const pressReady = db.prepare('SELECT COUNT(*) as c FROM candidates WHERE press_ready LIKE \'%yes%\' OR press_ready LIKE \'%Yes%\'').get().c;
+  const totalStories = db.prepare('SELECT COUNT(*) as c FROM stories').get().c;
+  const posted = db.prepare('SELECT COUNT(*) as c FROM stories WHERE status = \'posted\'').get().c;
+  const scheduled = db.prepare('SELECT COUNT(*) as c FROM stories WHERE status = \'scheduled\'').get().c;
+  res.json({ total, tierA, tierB, tierC, withStory, withConsent, pressReady, totalStories, posted, scheduled });
+});
+
+// ─── Story Generation ───
+app.post('/api/stories/generate', async (req, res) => {
+  const candidate = db.prepare('SELECT * FROM candidates WHERE id = ?').get(req.body.candidate_id);
+  if (!candidate) return res.status(404).json({ error: 'Candidate not found' });
+  let fullData = {};
+  try { fullData = JSON.parse(candidate.data_json || '{}'); } catch (e) { /* ignore */ }
+
+  try {
+    const story = await generateStory({ ...candidate, ...fullData });
+    const id = db.prepare('INSERT INTO stories (candidate_id, story_one_liner, instagram_caption, x_post, hashtags, story_category, lane) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
+      candidate.id, story.story_one_liner, story.instagram_caption, story.x_post,
+      JSON.stringify(story.hashtags || []), story.story_category || 'hardship', 'action'
+    ).lastInsertRowid;
+    res.json({ id, ...story });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.post('/api/stories/:id/generate-graphic', async (req, res) => {
+  const story = db.prepare('SELECT * FROM stories WHERE id = ?').get(req.params.id);
+  if (!story) return res.status(404).json({ error: 'Story not found' });
+  const candidate = db.prepare('SELECT * FROM candidates WHERE id = ?').get(story.candidate_id);
+  let fullData = {};
+  try { fullData = JSON.parse(candidate.data_json || '{}'); } catch (e) { /* ignore */ }
+
+  try {
+    const result = await generateGraphic({ ...candidate, ...fullData }, story.story_one_liner, { lane: story.lane });
+    if (result.imageBuffer) {
+      const imgPath = path.join(__dirname, 'generated', 'story-' + story.id + '.png');
+      fs.writeFileSync(imgPath, result.imageBuffer);
+      db.prepare('UPDATE stories SET image_path = ? WHERE id = ?').run('/generated/story-' + story.id + '.png', story.id);
+      res.json({ success: true, image_path: '/generated/story-' + story.id + '.png' });
+    } else {
+      res.status(500).json({ error: 'Image generation failed' });
+    }
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.post('/api/stories/:id/generate-lawmaker-graphic', async (req, res) => {
+  const story = db.prepare('SELECT * FROM stories WHERE id = ?').get(req.params.id);
+  if (!story) return res.status(404).json({ error: 'Story not found' });
+  const candidate = db.prepare('SELECT * FROM candidates WHERE id = ?').get(story.candidate_id);
+  if (!candidate.representative_name) return res.status(400).json({ error: 'No lawmaker mapped' });
+
+  const lawmaker = { name: candidate.representative_name, party: candidate.representative_party, district: candidate.district_code, state: (candidate.district_code || '').split('-')[0] };
+  try {
+    const result = await generateLawmakerGraphic(candidate, lawmaker, story.story_one_liner);
+    if (result.imageBuffer) {
+      const imgPath = path.join(__dirname, 'generated', 'lawmaker-' + story.id + '.png');
+      fs.writeFileSync(imgPath, result.imageBuffer);
+      db.prepare('UPDATE stories SET lawmaker_image_path = ? WHERE id = ?').run('/generated/lawmaker-' + story.id + '.png', story.id);
+      res.json({ success: true, image_path: '/generated/lawmaker-' + story.id + '.png' });
+    } else {
+      res.status(500).json({ error: 'Image generation failed' });
+    }
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Stories CRUD ───
+app.get('/api/stories', (req, res) => {
+  const status = req.query.status;
+  const platform = req.query.platform;
+  const limit = parseInt(req.query.limit) || 50;
+  let where = '1=1';
+  const params = [];
+  if (status) { where += ' AND s.status = ?'; params.push(status); }
+  if (platform) { where += ' AND s.platform = ?'; params.push(platform); }
+
+  const rows = db.prepare('SELECT s.*, c.first_name, c.last_name, c.amount_owed_raw, c.zip_code, c.tier, c.district_code, c.representative_name FROM stories s LEFT JOIN candidates c ON s.candidate_id = c.id WHERE ' + where + ' ORDER BY s.created_at DESC LIMIT ?').all(...params, limit);
+  res.json(rows);
+});
+
+app.get('/api/stories/:id', (req, res) => {
+  const story = db.prepare('SELECT s.*, c.first_name, c.last_name, c.amount_owed_raw, c.zip_code, c.tier, c.district_code, c.representative_name, c.emotional_impact, c.alt_spending, c.affordability_impacts_raw FROM stories s LEFT JOIN candidates c ON s.candidate_id = c.id WHERE s.id = ?').get(req.params.id);
+  if (!story) return res.status(404).json({ error: 'Not found' });
+  res.json(story);
+});
+
+app.put('/api/stories/:id', (req, res) => {
+  const fields = ['instagram_caption', 'x_post', 'status', 'scheduled_at', 'platform', 'lane'];
+  const updates = [];
+  const params = [];
+  for (const f of fields) {
+    if (req.body[f] !== undefined) { updates.push(f + ' = ?'); params.push(req.body[f]); }
+  }
+  if (updates.length === 0) return res.status(400).json({ error: 'No fields' });
+  params.push(req.params.id);
+  db.prepare('UPDATE stories SET ' + updates.join(', ') + ' WHERE id = ?').run(...params);
+  res.json({ success: true });
+});
+
+app.delete('/api/stories/:id', (req, res) => {
+  db.prepare('DELETE FROM stories WHERE id = ?').run(req.params.id);
+  res.json({ success: true });
+});
+
+// ─── Calendar ───
+app.get('/api/calendar', (req, res) => {
+  const stories = db.prepare('SELECT s.id, s.story_one_liner, s.status, s.scheduled_at, s.platform, s.lane, s.image_path, c.first_name, c.amount_owed_raw, c.tier FROM stories s LEFT JOIN candidates c ON s.candidate_id = c.id WHERE s.scheduled_at IS NOT NULL ORDER BY s.scheduled_at').all();
+  res.json(stories);
+});
+
+app.post('/api/calendar/auto-fill', async (req, res) => {
+  const days = parseInt(req.body.days) || 7;
+  const start = req.body.startDate ? new Date(req.body.startDate) : new Date();
+  const slots = [];
+
+  for (let d = 0; d < days; d++) {
+    const day = new Date(start);
+    day.setDate(day.getDate() + d);
+    const dateStr = day.toISOString().split('T')[0];
+    slots.push({ datetime: dateStr + 'T09:00:00-08:00', platform: 'instagram' });
+    slots.push({ datetime: dateStr + 'T18:00:00-08:00', platform: 'x' });
+  }
+
+  const usedIds = db.prepare('SELECT DISTINCT candidate_id FROM stories').all().map(r => r.candidate_id);
+  const placeholders = usedIds.length > 0 ? usedIds.map(() => '?').join(',') : '0';
+  const candidates = db.prepare('SELECT * FROM candidates WHERE tier IN (\'A\', \'B\') AND id NOT IN (' + placeholders + ') ORDER BY candidate_score DESC LIMIT ?').all(...usedIds, slots.length);
+
+  const results = [];
+  for (let i = 0; i < Math.min(slots.length, candidates.length); i++) {
+    const candidate = candidates[i];
+    let fullData = {};
+    try { fullData = JSON.parse(candidate.data_json || '{}'); } catch (e) { /* ignore */ }
+
+    try {
+      const story = await generateStory({ ...candidate, ...fullData });
+      const id = db.prepare('INSERT INTO stories (candidate_id, story_one_liner, instagram_caption, x_post, hashtags, story_category, lane, platform, status, scheduled_at) VALUES (?, ?, ?, ?, ?, ?, \'action\', ?, \'scheduled\', ?)').run(
+        candidate.id, story.story_one_liner, story.instagram_caption, story.x_post,
+        JSON.stringify(story.hashtags || []), story.story_category || 'hardship',
+        slots[i].platform, slots[i].datetime
+      ).lastInsertRowid;
+      results.push({ id, candidate: candidate.first_name, scheduled: slots[i].datetime, platform: slots[i].platform });
+    } catch (e) {
+      results.push({ error: e.message, candidate: candidate.first_name });
+    }
+    await new Promise(r => setTimeout(r, 1500));
+  }
+
+  res.json({ scheduled: results.length, slots: results });
+});
+
+// ─── Content Ideas ───
+app.get('/api/ideas', (req, res) => { res.json(CONTENT_IDEAS); });
+
+// ─── Data Import ───
+app.post('/api/import', (req, res) => {
+  try { res.json({ success: true, imported: loadCandidates() }); }
+  catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/import/refresh', async (req, res) => {
+  try {
+    await downloadFreshData();
+    res.json({ success: true, downloaded: true, imported: loadCandidates() });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// ─── Instagram OAuth ───
+app.get('/auth/instagram', (req, res) => {
+  const appId = getSetting('ig_app_id');
+  const redirectUri = encodeURIComponent(getSetting('app_url') + '/auth/instagram/callback');
+  res.redirect('https://www.facebook.com/v18.0/dialog/oauth?client_id=' + appId + '&redirect_uri=' + redirectUri + '&scope=instagram_basic,instagram_content_publish,pages_show_list,pages_read_engagement&response_type=code');
+});
+
+app.get('/auth/instagram/callback', async (req, res) => {
+  if (!req.query.code) return res.status(400).send('No code');
+  try {
+    const appId = getSetting('ig_app_id');
+    const appSecret = getSetting('ig_app_secret');
+    const redirectUri = getSetting('app_url') + '/auth/instagram/callback';
+    const tokenResult = await exchangeCodeForToken(appId, appSecret, redirectUri, req.query.code);
+    if (!tokenResult.access_token) return res.status(500).send('Token exchange failed');
+    const longLived = await getLongLivedToken(appId, appSecret, tokenResult.access_token);
+    setSetting('ig_access_token', longLived.access_token || tokenResult.access_token);
+    const igAccountId = await getIGBusinessAccount(longLived.access_token || tokenResult.access_token);
+    if (igAccountId) setSetting('ig_business_account_id', igAccountId);
+    res.redirect('/?connected=true');
+  } catch (e) { res.status(500).send('OAuth error: ' + e.message); }
+});
+
+app.get('/api/instagram/status', (req, res) => {
+  const token = getSetting('ig_access_token');
+  const accountId = getSetting('ig_business_account_id');
+  res.json({ connected: !!(token && accountId), hasToken: !!token, hasAccountId: !!accountId });
+});
+
+app.post('/api/stories/:id/publish', async (req, res) => {
+  const story = db.prepare('SELECT * FROM stories WHERE id = ?').get(req.params.id);
+  if (!story) return res.status(404).json({ error: 'Not found' });
+  if (!story.image_path) return res.status(400).json({ error: 'Generate graphic first' });
+  const token = getSetting('ig_access_token');
+  const accountId = getSetting('ig_business_account_id');
+  if (!token || !accountId) return res.status(400).json({ error: 'Instagram not connected' });
+
+  try {
+    const imageUrl = getSetting('app_url') + story.image_path;
+    const hashtags = JSON.parse(story.hashtags || '[]');
+    const caption = story.instagram_caption + '\n\n' + hashtags.map(function(h) { return '#' + h; }).join(' ');
+    const result = await publishToInstagram(accountId, token, imageUrl, caption);
+    db.prepare('UPDATE stories SET status = \'posted\', posted_at = datetime(\'now\'), ig_media_id = ? WHERE id = ?').run(result.id || '', story.id);
+    res.json({ success: true, mediaId: result.id });
+  } catch (e) {
+    db.prepare('UPDATE stories SET status = \'failed\' WHERE id = ?').run(story.id);
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Settings ───
+app.get('/api/settings', (req, res) => {
+  const all = db.prepare('SELECT * FROM settings').all();
+  const settings = {};
+  for (const row of all) {
+    settings[row.key] = (row.key.includes('secret') || row.key.includes('token')) ? '***' + row.value.slice(-6) : row.value;
+  }
+  res.json(settings);
+});
+
+app.put('/api/settings', (req, res) => {
+  for (const [key, value] of Object.entries(req.body)) {
+    if (value !== undefined && !String(value).startsWith('***')) setSetting(key, value);
+  }
+  res.json({ success: true });
+});
+
+// ─── Scheduler: 2x daily at 9am + 6pm PT ───
+cron.schedule('0 9,18 * * *', async () => {
+  if (getSetting('auto_schedule') !== 'true') return;
+  const now = new Date().toISOString();
+  const due = db.prepare('SELECT * FROM stories WHERE status = \'scheduled\' AND scheduled_at <= ? LIMIT 1').get(now);
+  if (!due) return;
+
+  const token = getSetting('ig_access_token');
+  const accountId = getSetting('ig_business_account_id');
+  if (!token || !accountId) return;
+
+  try {
+    if (due.platform === 'instagram' && due.image_path) {
+      const imageUrl = getSetting('app_url') + due.image_path;
+      const hashtags = JSON.parse(due.hashtags || '[]');
+      const caption = due.instagram_caption + '\n\n' + hashtags.map(function(h) { return '#' + h; }).join(' ');
+      const result = await publishToInstagram(accountId, token, imageUrl, caption);
+      db.prepare('UPDATE stories SET status = \'posted\', posted_at = datetime(\'now\'), ig_media_id = ? WHERE id = ?').run(result.id || '', due.id);
+      console.log('[Scheduler] Posted story #' + due.id + ' to Instagram');
+    } else {
+      db.prepare('UPDATE stories SET status = \'ready_to_post\' WHERE id = ?').run(due.id);
+    }
+  } catch (e) {
+    db.prepare('UPDATE stories SET status = \'failed\' WHERE id = ?').run(due.id);
+    console.error('[Scheduler] Failed:', e.message);
+  }
+}, { timezone: 'America/Los_Angeles' });
+
+// ─── Serve Frontend ───
+app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
+
+// ─── Start ───
+app.listen(PORT, '0.0.0.0', () => {
+  console.log('[StoryEngine] SDCC Story Engine running on port ' + PORT);
+  try {
+    const count = loadCandidates();
+    console.log('[StoryEngine] Loaded ' + count + ' candidates from CSV');
+  } catch (e) {
+    console.error('[StoryEngine] Initial import failed:', e.message);
+  }
+});

(oldest)  ·  back to SDCC Stories  ·  snapshot: 2 file(s) changed, ~2 modified ae4222d →