From e6cddbf3e2d744bb94e552741d019660a8449d0e Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 7 Jun 2026 07:00:13 +0100 Subject: [PATCH] Add full Cloudflare worker replacement --- cloudflare-d1-report-restore-worker-patch.js | 32 +- cloudflare-worker-full-replacement.js | 639 +++++++++++++++++++ 2 files changed, 663 insertions(+), 8 deletions(-) create mode 100644 cloudflare-worker-full-replacement.js diff --git a/cloudflare-d1-report-restore-worker-patch.js b/cloudflare-d1-report-restore-worker-patch.js index 714e462..3dfa763 100644 --- a/cloudflare-d1-report-restore-worker-patch.js +++ b/cloudflare-d1-report-restore-worker-patch.js @@ -50,11 +50,18 @@ function generateReportToken() { async function ensureReportTokenColumn(env) { if (!env.DB) return; - try { - await env.DB.exec("ALTER TABLE customers ADD COLUMN report_token TEXT"); - } catch (err) { - if (!String(err && err.message || err).includes("duplicate column")) { - console.error("D1 report_token migration error:", err); + 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 { @@ -102,12 +109,21 @@ async function handleReport(request, env, headers) { // 3) In initDB(env), after the CREATE TABLE exec finishes, call: // await ensureReportTokenColumn(env); -// 4) In storeCustomer(env, data), after the INSERT .run(), update the new token column. +// 4) In storeCustomer(env, data), after the INSERT .run(), update the new restore/profile columns. // Replace the return block after stmt.bind(...).run() with: async function storeCustomerReturnBlockExample(env, data, result) { const rowId = result.meta && result.meta.last_row_id || null; - if (rowId && data.report_token) { - await env.DB.prepare("UPDATE customers SET report_token = ? WHERE id = ?").bind(data.report_token, rowId).run(); + if (rowId) { + await env.DB.prepare(` + UPDATE customers + SET report_token = ?, protein_preference = ?, protein_security = ? + WHERE id = ? + `).bind( + data.report_token || "", + data.protein_preference || "", + data.protein_security || "", + rowId + ).run(); } return rowId; } diff --git a/cloudflare-worker-full-replacement.js b/cloudflare-worker-full-replacement.js new file mode 100644 index 0000000..22eba7b --- /dev/null +++ b/cloudflare-worker-full-replacement.js @@ -0,0 +1,639 @@ +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"; + if (ip !== "unknown" && env.RATE_LIMIT) { + const key = `submit:${ip}`; + const hits = parseInt(await env.RATE_LIMIT.get(key) || "0", 10); + if (hits >= 5) 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 ? "Direkt zu deinem Bericht" : "Jump back to your report"}
    + + + + + + + + + +
    ${isDE ? "Analyse" : "Analysis"}${isDE ? "Empfehlungen" : "Recommendations"}
    ${isDE ? "Budget" : "Budget"}${isDE ? "Zeitplan" : "Timeline"}
    + ${data.protein_security === "uncertain" ? `` : ""} +
    +
    +
    ${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 }); +}