Add D1 report restore links

This commit is contained in:
Dorian
2026-06-07 06:52:22 +01:00
parent 91ca1db006
commit f335d0dc94
3 changed files with 242 additions and 12 deletions

View File

@@ -45,12 +45,15 @@ function buildEmailTemplate(bodyText, data, isDE) {
};
const body = formatText(bodyText);
const reportUrl = "https://plan-b.now/#results-section";
const analysisUrl = "https://plan-b.now/#narrative-section";
const recommendationsUrl = "https://plan-b.now/#recs-anchor";
const budgetUrl = "https://plan-b.now/#panel-budget";
const timelineUrl = "https://plan-b.now/#panel-timeline";
const proteinUrl = "https://plan-b.now/#protein-offer-section";
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`;
return `<!doctype html>
<html lang="${isDE ? "de" : "en"}">

View File

@@ -0,0 +1,137 @@
/*
Paste these changes into the deployed Cloudflare Worker.
Goal:
- Store a private report_token against each D1 customers row.
- Return { report_id, report_token } from /submit.
- Add GET /report?id=<id>&token=<token> to restore the report from D1.
- Pass report_id/report_token into buildEmailTemplate so email links can use:
https://plan-b.now/?r=<id>&t=<token>#panel-budget
*/
// 1) In fetch(request, env, ctx), create url before the method guard and add GET /report.
// Replace the beginning of fetch with this shape:
async function fetch(request, env, ctx) {
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"
};
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
const JSON_H = { ...CORS, "Content-Type": "application/json" };
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/report") {
return handleReport(request, env, JSON_H);
}
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 new Response(JSON.stringify({ ok: false, error: "Invalid JSON" }), { status: 400, headers: JSON_H });
}
if (url.pathname === "/narrative") return handleNarrative(data, env, JSON_H);
return handleSubmit(data, env, ctx, JSON_H);
}
// 2) Add these helpers anywhere near the other helper functions.
function generateReportToken() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
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);
}
}
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 handleReport(request, env, headers) {
if (!env.DB) {
return new Response(JSON.stringify({ ok: false, error: "Database not configured" }), { status: 503, headers });
}
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 new Response(JSON.stringify({ ok: false, error: "Invalid report link" }), { status: 400, headers });
}
await initDB(env);
await ensureReportTokenColumn(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, language_used, protein_preference, protein_security
FROM customers
WHERE id = ? AND report_token = ?
LIMIT 1
`).bind(id, token).first();
if (!report) {
return new Response(JSON.stringify({ ok: false, error: "Report not found" }), { status: 404, headers });
}
return new Response(JSON.stringify({ ok: true, report }), { 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.
// 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();
}
return rowId;
}
// 5) In handleSubmit(data, env, ctx, headers), generate a token before storing/sending:
// const reportToken = generateReportToken();
// data.report_token = reportToken;
//
// Then store the customer synchronously before building the email:
// await initDB(env);
// await ensureReportTokenColumn(env);
// const reportId = await storeCustomer(env, data);
// data.report_id = reportId;
// data.report_token = reportToken;
//
// Remove or adjust the old ctx.waitUntil DB store block so it does not insert a duplicate row.
//
// Finally, return the report link fields:
// return new Response(JSON.stringify({
// ok: true,
// report_id: reportId,
// report_token: reportToken
// }), { headers });
// 6) In buildEmailTemplate(bodyText, data, isDE), use the updated
// cloudflare-buildEmailTemplate-replacement.js file. It already creates durable links
// when data.report_id and data.report_token are present.

View File

@@ -884,6 +884,7 @@ const T = {
}
const WORKER_URL = import.meta.env.VITE_WORKER_URL || 'https://planb-email.janwellmann.workers.dev/submit'
const WORKER_BASE_URL = WORKER_URL.replace(/\/submit\/?$/, '')
let currentLang = 'en'
function setLang(lang) {
@@ -1125,6 +1126,72 @@ function loadState() {
} catch { return null }
}
function splitStoredList(value) {
if (Array.isArray(value)) return value.filter(Boolean)
return String(value || '').split(',').map(item => item.trim()).filter(Boolean)
}
function answersFromReportRecord(record) {
return {
location: record.location || '',
household: record.household_size || '',
water: record.water_access || '',
food: record.food_reserves || '',
medical: record.medical_needs || '',
sanitation: record.sanitation || '',
budget: parseInt(record.budget_eur) || 1500,
scenarios: splitStoredList(record.scenarios || record.priorities),
protein_pref: splitStoredList(record.protein_preference),
protein_access: record.protein_security || '',
protein: record.protein_access || '',
_first_name: record.first_name || '',
_last_name: record.last_name || '',
_email: record.email || '',
_city: record.city || '',
_country: record.country || '',
_phone: record.phone || '',
}
}
function saveRestoredReport(record) {
answers = answersFromReportRecord(record)
currentQ = QUESTIONS.length - 1
currentScenario = 1
currentLang = record.preferred_language || record.language_used || currentLang
riskScore = parseInt(record.risk_score) || 0
riskLevelStr = record.risk_level || ''
try {
const payload = JSON.stringify({
stage: 'results',
currentQ,
answers,
currentScenario,
currentLang,
ts: Date.now(),
})
localStorage.setItem(STATE_KEY, payload)
sessionStorage.setItem(STATE_KEY, payload)
} catch {}
}
async function restoreReportFromUrl() {
const params = new URLSearchParams(window.location.search)
const id = params.get('r')
const token = params.get('t')
if (!id || !token) return false
const res = await fetch(`${WORKER_BASE_URL}/report?id=${encodeURIComponent(id)}&token=${encodeURIComponent(token)}`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
mode: 'cors',
})
if (!res.ok) throw new Error('Report restore failed')
const data = await res.json()
if (!data.ok || !data.report) throw new Error('Report restore missing report')
saveRestoredReport(data.report)
return true
}
// ══════════════════════════════════════
// QUIZ FLOW
// ══════════════════════════════════════
@@ -1666,6 +1733,14 @@ function submitCapture(e) {
_subject: (T[currentLang].brand || 'Plan-B') + ' — ' + lvl + ' — ' + firstName + ' ' + lastName + ' — ' + city + ', ' + country
}
answers._first_name = firstName
answers._last_name = lastName
answers._email = email
answers._city = city
answers._country = country
answers._phone = phone
saveState()
const showSuccess = () => {
const form = document.getElementById('capture-form')
const sub = document.querySelector('#capture-modal .capture-sub')
@@ -1694,7 +1769,15 @@ function submitCapture(e) {
body: JSON.stringify(payload)
}),
workerTimeout
]).catch(() => {})
])
.then(res => res && res.ok ? res.json() : null)
.then(data => {
if (!data || !data.report_id || !data.report_token) return
answers._report_id = String(data.report_id)
answers._report_token = String(data.report_token)
saveState()
})
.catch(() => {})
setTimeout(showSuccess, 1500)
}
@@ -2263,7 +2346,7 @@ function initPaintPicker() {
// ══════════════════════════════════════
// MOUNT
// ══════════════════════════════════════
onMounted(() => {
onMounted(async () => {
// Expose functions referenced by inline onclick / onsubmit / onchange handlers
window.startQuiz = startQuiz
window.restartQuiz = restartQuiz
@@ -2296,6 +2379,13 @@ onMounted(() => {
// / renderResults pick up the right strings. If no saved language, sniff
// navigator.language so German speakers land on the German copy without
// having to click the toggle.
let restoredFromRemote = false
try {
restoredFromRemote = await restoreReportFromUrl()
} catch (err) {
console.warn('Could not restore report from D1', err)
}
const saved = loadState()
const detectLang = () => {
const nav = (navigator.language || navigator.userLanguage || 'en').toLowerCase()
@@ -2305,10 +2395,10 @@ onMounted(() => {
setLang(lang)
updateRegionIndicator()
// Intro overlay — only on the very first home-page visit in this tab.
// If we're resuming a saved quiz/results stage, or the intro has already
// played in this session, jump straight to the hero with no animation.
const resumingMidFlow = !!(saved && saved.stage && saved.stage !== 'home')
// Intro overlay — only on the first visit. If we're resuming a saved
// quiz/results stage, or the intro has already been seen/skipped, jump
// straight to the hero with no animation.
const resumingMidFlow = restoredFromRemote || !!(saved && saved.stage && saved.stage !== 'home')
maybePlayIntro({ skip: resumingMidFlow })
if (saved && saved.stage && saved.stage !== 'home') {