const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Accept, Origin", "Access-Control-Max-Age": "86400" }; const JSON_HEADERS = { ...CORS, "Content-Type": "application/json" }; export default { async queue(batch, env) { for (const message of batch.messages) { try { await processAndSendEmail(message.body, env); message.ack(); } catch (err) { console.error("Queue processing error:", err); message.retry(); } } }, async fetch(request, env, ctx) { if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS }); const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/report") { return handleReport(request, env); } if (request.method !== "POST") { return new Response("Method not allowed", { status: 405, headers: CORS }); } let data; try { data = await request.json(); data._ip = request.headers.get("CF-Connecting-IP") || "unknown"; } catch { return json({ ok: false, error: "Invalid JSON" }, 400); } if (url.pathname === "/narrative") return handleNarrative(data, env); return handleSubmit(data, env, ctx); } }; async function initDB(env) { if (!env.DB) return; await env.DB.exec(` CREATE TABLE IF NOT EXISTS customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, submitted_at TEXT NOT NULL, first_name TEXT, last_name TEXT, email TEXT, city TEXT, country TEXT, phone TEXT, preferred_language TEXT, newsletter TEXT DEFAULT 'no', risk_level TEXT, risk_score INTEGER, location TEXT, household_size TEXT, water_access TEXT, food_reserves TEXT, medical_needs TEXT, sanitation TEXT, budget_eur INTEGER, scenarios TEXT, priorities TEXT, protein_access TEXT, protein_detail TEXT, protein_preference TEXT, protein_security TEXT, report_token TEXT, language_used TEXT, source TEXT DEFAULT 'plan-b.now' ); CREATE INDEX IF NOT EXISTS idx_email ON customers(email); CREATE INDEX IF NOT EXISTS idx_country ON customers(country); CREATE INDEX IF NOT EXISTS idx_risk_level ON customers(risk_level); CREATE INDEX IF NOT EXISTS idx_scenarios ON customers(scenarios); CREATE INDEX IF NOT EXISTS idx_submitted ON customers(submitted_at); CREATE INDEX IF NOT EXISTS idx_report_token ON customers(report_token); `); await ensureD1Columns(env); } async function ensureD1Columns(env) { if (!env.DB) return; const migrations = [ ["report_token", "ALTER TABLE customers ADD COLUMN report_token TEXT"], ["protein_preference", "ALTER TABLE customers ADD COLUMN protein_preference TEXT"], ["protein_security", "ALTER TABLE customers ADD COLUMN protein_security TEXT"] ]; for (const [name, sql] of migrations) { try { await env.DB.exec(sql); } catch (err) { if (!String(err && err.message || err).includes("duplicate column")) { console.error(`D1 ${name} migration error:`, err); } } } try { await env.DB.exec("CREATE INDEX IF NOT EXISTS idx_report_token ON customers(report_token)"); } catch (err) { console.error("D1 report_token index error:", err); } } async function storeCustomer(env, data) { if (!env.DB) return null; try { await initDB(env); const stmt = env.DB.prepare(` INSERT INTO customers ( submitted_at, first_name, last_name, email, city, country, phone, preferred_language, newsletter, risk_level, risk_score, location, household_size, water_access, food_reserves, medical_needs, sanitation, budget_eur, scenarios, priorities, protein_access, protein_detail, protein_preference, protein_security, report_token, language_used ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = await stmt.bind( data.submitted_at || new Date().toISOString(), data.first_name || "", data.last_name || "", data.email || "", data.city || "", data.country || "", data.phone || "", data.preferred_language || data.language_used || "en", data.newsletter || "no", data.risk_level || "", parseInt(data.risk_score) || 0, data.location || "", data.household_size || "", data.water_access || "", data.food_reserves || "", data.medical_needs || "", data.sanitation || "", parseInt(data.budget_eur) || 0, data.scenarios || "", data.priorities || "", data.protein_access || "", data.protein_detail || "", data.protein_preference || "", data.protein_security || "", data.report_token || "", data.language_used || "en" ).run(); return result.meta && result.meta.last_row_id || null; } catch (err) { console.error("D1 insert error:", err); return null; } } async function handleReport(request, env) { if (!env.DB) return json({ ok: false, error: "Database not configured" }, 503); const url = new URL(request.url); const id = parseInt(url.searchParams.get("id") || "0", 10); const token = String(url.searchParams.get("token") || "").trim(); if (!id || !token || token.length < 32) return json({ ok: false, error: "Invalid report link" }, 400); await initDB(env); const report = await env.DB.prepare(` SELECT id, submitted_at, first_name, last_name, email, city, country, phone, preferred_language, newsletter, risk_level, risk_score, location, household_size, water_access, food_reserves, medical_needs, sanitation, budget_eur, scenarios, priorities, protein_access, protein_detail, protein_preference, protein_security, language_used FROM customers WHERE id = ? AND report_token = ? LIMIT 1 `).bind(id, token).first(); if (!report) return json({ ok: false, error: "Report not found" }, 404); return json({ ok: true, report }); } async function handleNarrative(data, env) { const isDE = isGerman(data); let narrative = ""; try { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": env.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: "claude-sonnet-4-20250514", max_tokens: 1800, system: isDE ? "Du bist ein erfahrener Krisenvorsorge-Berater. Schreibe auf Deutsch. Warm, präzise, handlungsorientiert. Kein HTML. **Fett** für Abschnittsüberschriften, • für Aufzählungen." : "You are an experienced crisis preparedness advisor. English. Warm, precise, action-oriented. No HTML. **Bold** for headings, • for bullets.", messages: [{ role: "user", content: buildNarrativePrompt(data, isDE) }] }) }); if (res.ok) { const d = await res.json(); narrative = d.content && d.content[0] && d.content[0].text || ""; } } catch {} if (!narrative || narrative.length < 100) narrative = getFallbackNarrative(data, isDE); return json({ ok: true, narrative }); } async function handleSubmit(data, env, ctx) { try { const ip = data._ip || "unknown"; const emailKey = String(data.email || "unknown").trim().toLowerCase(); const skipRateLimit = ip === "109.146.77.109" || emailKey === "test-x5guc3r39@srv1.mail-tester.com"; if (!skipRateLimit && ip !== "unknown" && env.RATE_LIMIT) { const key = `submit:${ip}:${emailKey}`; const hits = parseInt(await env.RATE_LIMIT.get(key) || "0", 10); if (hits >= 20) return json({ ok: false, error: "Too many submissions. Please try again later." }, 429); await env.RATE_LIMIT.put(key, String(hits + 1), { expirationTtl: 3600 }); } } catch (err) { console.error("Rate limit check error:", err); } const reportToken = generateReportToken(); data.report_token = reportToken; const reportId = await storeCustomer(env, data); data.report_id = reportId; data.report_token = reportToken; const emailAddr = data.email; if (emailAddr && emailAddr.includes("@")) { if (env.EMAIL_QUEUE) { ctx.waitUntil(env.EMAIL_QUEUE.send(data)); } else { ctx.waitUntil(processAndSendEmail(data, env)); } } return json({ ok: true, report_id: reportId, report_token: reportToken }); } async function processAndSendEmail(data, env) { const emailAddr = data.email; if (!emailAddr || !emailAddr.includes("@")) return; const isDE = isGerman(data); let emailBody = ""; try { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": env.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: "claude-sonnet-4-20250514", max_tokens: 1600, system: isDE ? "Krisenvorsorge-Berater. Persönliche E-Mails auf Deutsch. Kein HTML. • für Listen. **Fett** für Überschriften. Warm und direkt." : "Crisis preparedness advisor. Personal emails in English. No HTML. • for lists. **Bold** for headings. Warm and direct.", messages: [{ role: "user", content: buildEmailPrompt(data, isDE) }] }) }); if (res.ok) { const d = await res.json(); emailBody = d.content && d.content[0] && d.content[0].text || ""; } } catch {} if (!emailBody || emailBody.length < 100) emailBody = getFallbackEmailText(data, isDE); const firstName = data.first_name || ""; const subject = isDE ? `${firstName ? firstName + ", dein Notfallplan ist bereit" : "Dein Notfallplan ist bereit"}` : `${firstName ? firstName + ", your preparedness plan is ready" : "Your preparedness plan is ready"}`; const emailHtml = buildEmailTemplate(emailBody, data, isDE); const fromAddresses = ["Plan B Now ", "Plan B Now "]; for (const fromAddr of fromAddresses) { try { const resendRes = await fetch("https://api.resend.com/emails", { method: "POST", headers: { "Authorization": `Bearer ${env.RESEND_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ from: fromAddr, to: [emailAddr], reply_to: "hello@plan-b.now", subject, html: emailHtml, headers: { "List-Unsubscribe": "", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", "X-Entity-Ref-ID": `${emailAddr}-${Date.now()}` } }) }); const resendBody = await resendRes.json().catch(() => ({})); console.log(`Resend [${fromAddr}] status:`, resendRes.status, JSON.stringify(resendBody).substring(0, 200)); if (resendRes.ok) return; console.error(`Resend failed [${fromAddr}]:`, resendBody.message || resendBody.name); } catch (err) { console.error(`Resend exception [${fromAddr}]:`, err.message); } } } function generateReportToken() { const bytes = new Uint8Array(24); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function isGerman(data) { return (data.preferred_language || data.language_used || "en") === "de"; } function buildNarrativePrompt(data, isDE) { const name = data.first_name || (isDE ? "du" : "you"); const city = data.city || (isDE ? "deiner Stadt" : "your city"); const country = data.country || ""; const risk = getRiskLabel(data.risk_level, isDE); const budget = data.budget_eur ? `EUR ${data.budget_eur}` : ""; const household = householdLabel(data.household_size, isDE); const scenarios = (data.scenarios || "").split(",").map((s) => s.trim()).filter(Boolean); const protein = data.protein_access || data.protein_detail || ""; const scenarioLabels = getScenarioLabels(isDE); const selectedScenarios = scenarios.map((s) => scenarioLabels[s] || s).join(", ") || (isDE ? "allgemeine Vorsorge" : "general preparedness"); if (isDE) return `Erstelle eine personalisierte Krisenvorsorge-Analyse für ${name} aus ${city}, ${country}. PROFIL: Risiko ${risk} | ${household} | Budget ${budget} GEWÄHLTE SZENARIEN: ${selectedScenarios} BEDENKEN: ${data.priorities || "allgemein"} | Protein: ${protein || "keine"} LÜCKEN: Wasser (${getStatusLabel(data.water_access, true)}) | Lebensmittel (${getStatusLabel(data.food_reserves, true)}) | Sanitär (${getStatusLabel(data.sanitation, true)}) Schreibe eine vollständige, persönliche Analyse mit **Fettschrift**-Überschriften und • Listen. Warm, sachlich, nicht alarmistisch.`; return `Create a personalised crisis preparedness analysis for ${name} from ${city}, ${country}. PROFILE: Risk ${risk} | ${household} | Budget ${budget} SELECTED SCENARIOS: ${selectedScenarios} CONCERNS: ${data.priorities || "general"} | Protein: ${protein || "none"} GAPS: Water (${getStatusLabel(data.water_access, false)}) | Food (${getStatusLabel(data.food_reserves, false)}) | Sanitation (${getStatusLabel(data.sanitation, false)}) Write a complete personalised analysis with **Bold** headings and • bullets. Warm, factual, not alarmist.`; } function buildEmailPrompt(data, isDE) { const fullName = [data.first_name, data.last_name].filter(Boolean).join(" ") || (isDE ? "dort" : "there"); const city = data.city || ""; const country = data.country || ""; const risk = getRiskLabel(data.risk_level, isDE); const budget = data.budget_eur ? `EUR ${data.budget_eur}` : ""; const household = householdLabel(data.household_size, isDE); const protein = data.protein_access || data.protein_detail || ""; const scenarios = (data.scenarios || "").split(",").map((s) => s.trim()).filter(Boolean); const scenarioLabels = getScenarioLabels(isDE); const selectedScenarios = scenarios.map((s) => scenarioLabels[s] || s).join(", ") || (isDE ? "allgemeine Vorsorge" : "general preparedness"); const base = "https://plan-b.now/go?p="; const links = { water: base + "awg", food: base + "rice", sprouts: base + "sprouts", solar: base + "ecoflow", protein: base + "protein" }; if (isDE) return `Schreibe eine vollständige, persönliche Willkommens-E-Mail an ${fullName} aus ${city}, ${country}. PROFIL: ${risk} | ${household} | Budget ${budget} GEWÄHLTE SZENARIEN: ${selectedScenarios} Protein: ${protein || "keine"} Struktur: 1. Liebe/Lieber ${fullName}, herzliche Begrüßung und Dank fürs Assessment. 2. Kurze Erklärung des Risikos ${risk} für ${city}. 3. Die 3 dringendsten Maßnahmen: • Wasserversorgung: ${links.water} • Lebensmittelvorrat: ${links.food} • Keimsprossen-Set: ${links.sprouts} • Solar-Energie: ${links.solar} ${data.protein_security === "uncertain" ? "• Proteinquelle: " + links.protein : ""} 4. Kurzer Abschnitt zu den gewählten Szenarien. 5. Ermutigung + Hinweis auf wöchentliche Updates. Ton: Wie ein erfahrener Freund. KEIN HTML. • für Listen.`; return `Write a complete personal welcome email to ${fullName} from ${city}, ${country}. PROFILE: ${risk} | ${household} | Budget ${budget} SELECTED SCENARIOS: ${selectedScenarios} Protein: ${protein || "none"} Structure: 1. Dear ${fullName}, warm welcome and thanks for the assessment. 2. Brief explanation of risk ${risk} for ${city}. 3. The 3 most urgent actions: • Water security: ${links.water} • Food reserves: ${links.food} • Sprouting kit: ${links.sprouts} • Solar power: ${links.solar} ${data.protein_security === "uncertain" ? "• Protein source: " + links.protein : ""} 4. Brief section on selected scenarios. 5. Encouraging close + weekly updates. Tone: Like advice from an experienced friend. NO HTML. • for lists.`; } function buildEmailTemplate(bodyText, data, isDE) { const esc = (v) => String(v ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); const fullName = [data.first_name, data.last_name].filter(Boolean).join(" ") || ""; const greeting = fullName ? isDE ? `Liebe/Lieber ${esc(fullName)}` : `Hello ${esc(fullName)}` : isDE ? "Hallo" : "Hello"; const risk = data.risk_level || "ASSESSED"; const scenarios = (data.scenarios || "").split(",").map((s) => s.trim()).filter(Boolean); const scenarioLabels = getScenarioLabels(isDE); const scenarioBadges = scenarios.map((s) => `${esc(scenarioLabels[s] || s)}` ).join(""); const formatText = (text) => { return esc(text).split(/\n\n+/).filter((p) => p.trim()).map((p) => { if (p.startsWith("* ") || p.startsWith("- ") || p.startsWith("•") || p.includes("\n•")) { const items = p.split("\n").filter((l) => l.trim()).map((l) => { const clean = l.trim().replace(/^(\*|-|•)\s*/, ""); const linked = clean.replace(/(https?:\/\/[^\s<]+)/g, `${isDE ? "Ansehen" : "View"}`); const bolded = linked.replace(/\*\*(.+?)\*\*/g, '$1'); return `
  • ${bolded}
  • `; }).join(""); return `
      ${items}
    `; } const linked = p.replace(/(https?:\/\/[^\s<]+)/g, `${isDE ? "Ansehen" : "View"}`); const bolded = linked.replace(/\*\*(.+?)\*\*/g, '$1'); return `

    ${bolded}

    `; }).join(""); }; const restoreQuery = data.report_id && data.report_token ? `?r=${encodeURIComponent(data.report_id)}&t=${encodeURIComponent(data.report_token)}` : ""; const reportUrl = `https://plan-b.now/${restoreQuery}#results-section`; const analysisUrl = `https://plan-b.now/${restoreQuery}#narrative-section`; const recommendationsUrl = `https://plan-b.now/${restoreQuery}#recs-anchor`; const budgetUrl = `https://plan-b.now/${restoreQuery}#panel-budget`; const timelineUrl = `https://plan-b.now/${restoreQuery}#panel-timeline`; const proteinUrl = `https://plan-b.now/${restoreQuery}#protein-offer-section`; const body = formatText(bodyText); return ` ${isDE ? "Dein Plan-B Notfallplan" : "Your Plan-B Preparedness Plan"}
    Plan-B
    ${isDE ? "Persoenlicher Vorsorgeplan" : "Personal Preparedness Plan"}

    ${esc(getRiskLabel(risk, isDE))} ${isDE ? "Bericht" : "Readiness Report"}

    ${greeting},

    ${isDE ? "Risikoprofil" : "Risk Profile"}
    ${esc(getRiskLabel(risk, isDE))}
    ${scenarioBadges ? `
    ${scenarioBadges}
    ` : ""}
    ${body}
    ${isDE ? "Dein Profil" : "Your Profile"}
    ${erow(isDE ? "Name" : "Name", esc(fullName))} ${erow(isDE ? "Wohnort" : "Location", esc([data.city, data.country].filter(Boolean).join(", ")))} ${erow(isDE ? "Haushalt" : "Household", esc(householdLabel(data.household_size, isDE)))} ${erow(isDE ? "Budget" : "Budget", data.budget_eur ? `EUR ${esc(data.budget_eur)}` : "")} ${erow(isDE ? "Szenarien" : "Scenarios", esc(scenarios.map((s) => scenarioLabels[s] || s).join(", ")))}

    ${isDE ? 'Du erhaeltst diese E-Mail, weil du das Plan-B Assessment abgeschlossen hast.' : 'You are receiving this because you completed the Plan-B assessment.'} ${isDE ? "Abmelden" : "Unsubscribe"}. ${isDE ? "Deine Daten werden nie verkauft." : "Your data is never sold."}

    `; } function erow(label, value) { if (!value || value === "—" || !String(value).trim()) return ""; return `${label}${value}`; } function getFallbackNarrative(data, isDE) { if (isDE) return `**DEINE AKTUELLE LAGE** Dein Assessment zeigt, wo die wichtigsten Lücken sind. Du hast den ersten entscheidenden Schritt gemacht. **NÄCHSTE SCHRITTE** • Wasser zuerst sichern • Lebensmittelvorrat auf zwei Wochen bringen • Energie, Medizin und Hygiene als zweite Redundanz planen`; return `**YOUR CURRENT SITUATION** Your assessment shows where the key gaps are. You have taken the first decisive step. **NEXT STEPS** • Secure water first • Build food reserves toward two weeks • Add redundancy for energy, medical needs, and sanitation`; } function getFallbackEmailText(data, isDE) { const fullName = [data.first_name, data.last_name].filter(Boolean).join(" ") || ""; const risk = getRiskLabel(data.risk_level, isDE); if (isDE) return `Liebe/Lieber ${fullName || "dort"}, Danke für das Plan-B Assessment. Risikoniveau: ${risk}. • Wasser zuerst sichern • Lebensmittelvorrat aufbauen • Energie und Hygiene redundant planen Wöchentliche Updates folgen. Plan-B Team`; return `Dear ${fullName || "there"}, Thank you for the Plan-B assessment. Risk level: ${risk}. • Secure water first • Build food reserves • Add redundancy for energy and hygiene Weekly updates to follow. Plan-B Team`; } function getScenarioLabels(isDE) { return { total_collapse: isDE ? "Totaler Kollaps" : "Total Collapse", partial_collapse: isDE ? "Partieller Kollaps" : "Partial Collapse", hyperinflation: isDE ? "Hyperinflation" : "Hyperinflation", supply_shock: isDE ? "Versorgungsengpass" : "Supply Side Shock", food_crisis: isDE ? "Lebensmittelkrise" : "Food Crisis", bank_crisis: isDE ? "Banken- / Finanzkrise" : "Bank / Financial Crisis", power_outage: isDE ? "Längerer Stromausfall" : "Extended Power Outage", water_failure: isDE ? "Wasserversorgungsausfall" : "Water Supply Failure", war: isDE ? "Krieg / Bewaffneter Konflikt" : "War / Armed Conflict" }; } function getRiskLabel(level, isDE) { const map = { CRITICAL: { en: "Critical", de: "Kritisch" }, "HIGH RISK": { en: "High Risk", de: "Hohes Risiko" }, HIGH: { en: "High Risk", de: "Hohes Risiko" }, MODERATE: { en: "Moderate", de: "Mäßig" }, PREPARED: { en: "Prepared", de: "Vorbereitet" } }; const entry = map[level] || { en: level || "Assessed", de: level || "Bewertet" }; return isDE ? entry.de : entry.en; } function householdLabel(size, isDE) { const map = { "1": { en: "1 person", de: "1 Person" }, "2": { en: "2 people", de: "2 Personen" }, "4": { en: "3-4 people", de: "3-4 Personen" }, "6": { en: "5-6 people", de: "5-6 Personen" }, "8": { en: "7+ people", de: "7+ Personen" } }; const entry = map[size] || { en: size || "—", de: size || "—" }; return isDE ? entry.de : entry.en; } function getStatusLabel(value, isDE) { const map = { none: { en: "None", de: "Keine" }, minimal: { en: "Minimal", de: "Minimal" }, filter: { en: "Filter + storage", de: "Filter + Vorrat" }, solid: { en: "Solid 1mo+", de: "Gut 1Mo+" }, week: { en: "~1 week", de: "~1 Woche" }, month: { en: "1-3 months", de: "1-3 Monate" }, mild: { en: "Mild", de: "Gering" }, moderate: { en: "Moderate", de: "Mäßig" }, high: { en: "High-critical", de: "Hoch-kritisch" }, basic: { en: "Basic only", de: "Nur Grundbedarf" }, bucket: { en: "Bucket system", de: "Eimer-System" }, full: { en: "Full kit", de: "Vollständig" } }; const entry = map[value] || { en: value || "—", de: value || "—" }; return isDE ? entry.de : entry.en; } function json(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS }); }