- |
- Hi {{first_name}}, your assessment is complete. Below is the compact version of your plan, built from your household details, location, priorities, and current supply buffer.
+ |
+ Hi {{first_name}}, your assessment is complete. Here is the compact field version of your preparedness plan, built from your household details, location, priorities, and current supply buffer.
-
+
Assessment Summary
- {{narrative_summary}}
+ {{narrative_summary}}
-
+
-
+
Risk Score
{{risk_score}}
|
-
-
+
+
Priority Scenario
{{primary_scenario}}
@@ -74,32 +76,32 @@
| |
-
+
Immediate Actions
-
+
First 7 Days
- {{action_week_1}}
+ {{action_week_1}}
-
+
First 30 Days
- {{action_month_1}}
+ {{action_month_1}}
-
+
Recommended Supplies
-
-
+
- |
+ |
|
diff --git a/worker/src/index.js b/worker/src/index.js
new file mode 100644
index 0000000..db75088
--- /dev/null
+++ b/worker/src/index.js
@@ -0,0 +1,198 @@
+import { renderEmailTemplate } from "./template.js";
+
+const JSON_HEADERS = {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Accept",
+};
+
+export default {
+ async fetch(request, env) {
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: JSON_HEADERS });
+
+ const url = new URL(request.url);
+ if (request.method === "POST" && url.pathname === "/submit") return submit(request, env);
+ if (request.method === "POST" && url.pathname === "/narrative") return narrative(request);
+ if (request.method === "GET" && url.pathname === "/health") return json({ ok: true });
+
+ return json({ ok: false, error: "Not found" }, 404);
+ },
+};
+
+async function submit(request, env) {
+ const payload = await readJson(request);
+ const email = String(payload.email || "").trim();
+ if (!email || !email.includes("@")) return json({ ok: false, error: "Valid email is required" }, 400);
+
+ const model = buildEmailModel(payload, env);
+ const html = renderEmailTemplate(model);
+ const text = renderTextEmail(model);
+
+ if (!env.RESEND_API_KEY || !env.FROM_EMAIL) {
+ return json({
+ ok: true,
+ dry_run: true,
+ message: "Email rendered but not sent. Configure RESEND_API_KEY and FROM_EMAIL Worker secrets.",
+ preview: model,
+ });
+ }
+
+ const result = await sendWithResend({
+ apiKey: env.RESEND_API_KEY,
+ from: env.FROM_EMAIL,
+ to: email,
+ replyTo: env.REPLY_TO_EMAIL || env.FROM_EMAIL,
+ subject: `${model.brand} - ${model.risk_level} readiness report`,
+ html,
+ text,
+ });
+
+ return json({ ok: true, provider: "resend", id: result.id || null });
+}
+
+async function narrative(request) {
+ const payload = await readJson(request);
+ return json({ narrative: buildNarrative(payload) });
+}
+
+function buildEmailModel(payload, env) {
+ const brand = env.BRAND_NAME || "Plan-B";
+ const riskLevel = payload.risk_level || riskLevelFromScore(payload.risk_score);
+ const scenarios = splitList(payload.scenarios || payload.priorities);
+ const primaryScenario = scenarios[0] || "Personal preparedness";
+
+ return {
+ brand,
+ kicker: "Personal Preparedness Plan",
+ first_name: payload.first_name || "there",
+ risk_level: riskLevel,
+ risk_score: payload.risk_score ?? "Not scored",
+ primary_scenario: primaryScenario,
+ narrative_summary: buildSummary(payload, riskLevel, primaryScenario),
+ action_week_1: buildWeekOneAction(payload),
+ action_month_1: buildMonthOneAction(payload),
+ recommendations: buildRecommendations(payload),
+ plan_url: env.SITE_URL || "https://planb.example",
+ unsubscribe_url: env.UNSUBSCRIBE_URL || env.SITE_URL || "https://planb.example",
+ };
+}
+
+function buildSummary(payload, riskLevel, primaryScenario) {
+ const household = payload.household_size ? ` for a household size of ${payload.household_size}` : "";
+ const city = payload.city ? ` in ${payload.city}` : "";
+ return `Your current profile is ${riskLevel}${household}${city}. The top planning focus is ${primaryScenario}, with water, food, energy, and medical continuity reviewed against your current supply buffer.`;
+}
+
+function buildWeekOneAction(payload) {
+ const gaps = [];
+ if (payload.water_access === "none" || payload.water_access === "minimal") gaps.push("secure a drinking-water buffer and filtration path");
+ if (payload.food_reserves === "none" || payload.food_reserves === "week") gaps.push("build a two-week dry food reserve");
+ if (payload.medical_needs === "high") gaps.push("separate critical medication and first-aid reserves");
+ return sentence(gaps, "Audit your current supplies, buy the missing basics, and document a 7-day household continuity checklist.");
+}
+
+function buildMonthOneAction(payload) {
+ const budget = payload.budget_eur || 1500;
+ return `Use the first month to turn the initial buffer into a resilient system: water redundancy, shelf-stable calories, backup power, sanitation, and a prioritized supply budget of about EUR ${budget}.`;
+}
+
+function buildRecommendations(payload) {
+ const items = [
+ "water storage or filtration sized to the household",
+ "shelf-stable calorie base with simple rotation",
+ "backup power for phone, light, and critical devices",
+ "first-aid, ORS, hygiene, and sanitation supplies",
+ ];
+ if (payload.protein_access || payload.protein_detail) items.push("protein strategy based on your local access notes");
+ return items.join("; ") + ".";
+}
+
+function buildNarrative(payload) {
+ const riskLevel = payload.risk_level || riskLevelFromScore(payload.risk_score);
+ const primaryScenario = splitList(payload.scenarios || payload.priorities)[0] || "preparedness";
+ const isGerman = payload.language_used === "de" || payload.preferred_language === "de";
+
+ if (isGerman) {
+ return `**DEINE LAGE**\n\nDein aktuelles Profil liegt bei ${riskLevel}. Der wichtigste Fokus ist ${primaryScenario}.\n\n**NÄCHSTE SCHRITTE**\n\n• Wasserpuffer und Filter zuerst sichern\n• Lebensmittelvorrat auf mindestens zwei Wochen bringen\n• Energie, Medizin und Hygiene als zweite Redundanz planen`;
+ }
+
+ return `**YOUR SITUATION**\n\nYour current profile is ${riskLevel}. The most important focus is ${primaryScenario}.\n\n**NEXT STEPS**\n\n• Secure water buffer and filtration first\n• Build food reserves toward at least two weeks\n• Add redundancy for energy, medical needs, and sanitation`;
+}
+
+function sentence(items, fallback) {
+ if (!items.length) return fallback;
+ return `This week, ${items.join("; ")}.`;
+}
+
+function splitList(value) {
+ if (Array.isArray(value)) return value.filter(Boolean);
+ return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
+}
+
+function riskLevelFromScore(score) {
+ const n = Number(score || 0);
+ if (n >= 8) return "CRITICAL";
+ if (n >= 5) return "HIGH RISK";
+ if (n >= 3) return "MODERATE";
+ return "PREPARED";
+}
+
+function renderTextEmail(model) {
+ return [
+ `${model.brand} - ${model.risk_level} Readiness Report`,
+ "",
+ `Hi ${model.first_name},`,
+ "",
+ model.narrative_summary,
+ "",
+ `Risk score: ${model.risk_score}`,
+ `Priority scenario: ${model.primary_scenario}`,
+ "",
+ "First 7 days:",
+ model.action_week_1,
+ "",
+ "First 30 days:",
+ model.action_month_1,
+ "",
+ "Recommended supplies:",
+ model.recommendations,
+ "",
+ `Full plan: ${model.plan_url}`,
+ `Unsubscribe: ${model.unsubscribe_url}`,
+ ].join("\n");
+}
+
+async function sendWithResend({ apiKey, from, to, replyTo, subject, html, text }) {
+ const response = await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ from,
+ to,
+ reply_to: replyTo,
+ subject,
+ html,
+ text,
+ }),
+ });
+
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(`Resend failed: ${response.status} ${JSON.stringify(body)}`);
+ return body;
+}
+
+async function readJson(request) {
+ try {
+ return await request.json();
+ } catch {
+ return {};
+ }
+}
+
+function json(body, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
+}
diff --git a/worker/src/template.js b/worker/src/template.js
new file mode 100644
index 0000000..a507e8d
--- /dev/null
+++ b/worker/src/template.js
@@ -0,0 +1,129 @@
+const TEMPLATE = `
+
+
+
+
+ {{brand}} Preparedness Plan
+
+
+
+
+
+
+
+
+
+
+
+ |
+ Plan-B
+ {{kicker}}
+ {{risk_level}} Readiness Report
+ |
+
+
+ |
+ Hi {{first_name}}, your assessment is complete. Here is the compact field version of your preparedness plan, built from your household details, location, priorities, and current supply buffer.
+
+
+ Assessment Summary
+ {{narrative_summary}}
+
+
+
+
+
+
+ Risk Score
+ {{risk_score}}
+
+ |
+
+
+ Priority Scenario
+ {{primary_scenario}}
+
+ |
+
+
+
+
+ Immediate Actions
+
+ First 7 Days
+ {{action_week_1}}
+
+
+ First 30 Days
+ {{action_month_1}}
+
+
+
+
+ Recommended Supplies
+
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+`;
+
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+export function renderEmailTemplate(data) {
+ return TEMPLATE.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, key) => escapeHtml(data[key]));
+}
diff --git a/worker/wrangler.toml b/worker/wrangler.toml
new file mode 100644
index 0000000..99fef86
--- /dev/null
+++ b/worker/wrangler.toml
@@ -0,0 +1,14 @@
+name = "planb-email"
+main = "src/index.js"
+compatibility_date = "2026-06-03"
+
+[vars]
+BRAND_NAME = "Plan-B"
+SITE_URL = "https://plan-b.kammergut.example"
+UNSUBSCRIBE_URL = "https://plan-b.kammergut.example/unsubscribe"
+
+# Set these as Worker secrets before deploying:
+# npx wrangler secret put RESEND_API_KEY --config worker/wrangler.toml
+# npx wrangler secret put FROM_EMAIL --config worker/wrangler.toml
+# Optional:
+# npx wrangler secret put REPLY_TO_EMAIL --config worker/wrangler.toml
|