← back to SDCC Stories
initial scaffold (gitify-all 2026-05-06)
5f05dd42b2bdb2c5e152bb556e03a40f54337bed · 2026-05-06 10:25:12 -0700 · Steve Abrams
Files touched
A .gitignoreA db/stories.dbA db/stories.db-shmA db/stories.db-walA ecosystem.config.jsA lib/data-import.jsA lib/gemini.jsA lib/instagram-api.jsA package-lock.jsonA package.jsonA public/css/styles.cssA public/favicon.svgA public/index.htmlA public/js/app.jsA server.js
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 → 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()">×</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 →