From 357229193311032f025004e939b94bcc674c328d Mon Sep 17 00:00:00 2001 From: gator Date: Thu, 22 Oct 2026 14:05:19 +0000 Subject: [PATCH] Fix reminder postal configuration and require deliberate RSVP confirmation Link previews no longer submit answers. Failed queue attempts retain reusable drafts; recipient queues prevent duplicate retries. Tested: 18 isolated regressions pass; 13 fail on baseline. AI-Agent: Codex (OpenAI) AI-Session: 01a14186-e2ee-77b1-9526-a32c58cd6a00 --- ops/README.md | 20 ++++- ops/REPAIR_2026-10-22.md | 24 +++++ ops/founders-break-reminder.js | 55 ++++++++---- ops/founders-break-rsvp.js | 11 ++- ops/regression.test.cjs | 160 +++++++++++++++++++++++++++++++++ public/js/main.js | 96 +++++++++++++------- views/index.ejs | 2 +- 7 files changed, 317 insertions(+), 51 deletions(-) create mode 100644 ops/REPAIR_2026-10-22.md create mode 100644 ops/regression.test.cjs diff --git a/ops/README.md b/ops/README.md index 19db4cc..c64b672 100644 --- a/ops/README.md +++ b/ops/README.md @@ -15,7 +15,7 @@ jq -n --rawfile code ops/founders-break-rsvp.js '{code:$code}' \ ``` The reminder is `cf2a1e4c-43f9-4cf4-8fc5-652c494940ae`. Their `env` (CORBEN_KEY, -LIST_ID, FROM, FROM_ADDRESS_ID, NOTIFY) is set on the function record and is not +LIST_ID, FROM, FROM_ADDRESS_ID, NOTIFY, and reminder POSTAL_ADDRESS) is set on the function record and is not in these files. Never paste the key into this repo. Test without waiting for a real submission: @@ -24,3 +24,21 @@ Test without waiting for a real submission: execute_function id=3d3c4bed-... event={"form_name":"rsvp","data":{"name":"...","email":"..."}} execute_function id=cf2a1e4c-... event={"force":true} # sends for real ``` + +## October 22 reliability repair + +Email links now display a confirmation screen. Opening or previewing a link +does not POST a form. The RSVP function also rejects reminder responses without +the confirmation flag, and repeated answers send no duplicate notifications. +New reminder links carry their event date; stale dated links cannot answer for +the current week. Existing undated links show the current event date explicitly +before confirmation. + +POSTAL_ADDRESS is required before campaign creation and is passed in the +send_campaign delivery configuration. The reminder reuses an unsent draft after +failure, records prepared versus queued state, and checks the campaign status +and durable recipients before retrying. Queued, sent or paused campaigns are +never re-sent. A prepared marker alone is not a successful send. + +Run `node --test ops/regression.test.cjs` for network-isolated source tests. +They mock all API calls, including mail. Never use `force: true` as a smoke test. diff --git a/ops/REPAIR_2026-10-22.md b/ops/REPAIR_2026-10-22.md new file mode 100644 index 0000000..02f217b --- /dev/null +++ b/ops/REPAIR_2026-10-22.md @@ -0,0 +1,24 @@ +# Reminder and RSVP repair + +The owner requested correction of the missing postal-address setting and rapid +yes/no flips. An audit found that the browser automatically posted either +answer on page load. Email security scanning is a plausible explanation for +the observed pairs, but no scanner identity was established. + +The candidate adds explicit browser confirmation, rejects unconfirmed reminder +submissions before the function writes or sends anything, and suppresses repeat +notifications for unchanged answers. New links include the event date. The +ordinary site form keeps its existing registration flow. + +The reminder receives the owner's previously authorized OneTouch postal address +as a private environment setting and supplies it in paced delivery configuration. +It records prepared and queued states separately, reuses a failed draft and +checks for existing recipient queues before retrying. The October 22 draft's +delivery metadata is corrected without queueing it or changing its date/copy. + +The same 18 isolated regression cases report 13 failures on the pre-change +sources and all pass on the candidate. No actual email, attendee response or +contact cleanup is part of this repair. Existing answers are retained because +the historic link traffic does not establish which changes were intentional. + +Local evidence: `/Users/devingator/Documents/AITesting/outputs/founders-break-fix-20261022/`. diff --git a/ops/founders-break-reminder.js b/ops/founders-break-reminder.js index 0d6c71c..d255c52 100644 --- a/ops/founders-break-reminder.js +++ b/ops/founders-break-reminder.js @@ -6,7 +6,7 @@ // makes a double-fire a no-op, which is what lets the job run more than once a // day without ever mailing the list twice. // -// env: CORBEN_KEY, LIST_ID, FROM, NOTIFY +// env: CORBEN_KEY, LIST_ID, FROM, FROM_ADDRESS_ID, NOTIFY, POSTAL_ADDRESS // event: { force: true } sends regardless of the day, for testing. var event = ctx.event, env = ctx.env, fetch = ctx.fetch, log = ctx.log, kv = ctx.kv; @@ -135,12 +135,12 @@ function button(label, href) { } -// Yes / No in one tap. The links land on the site, which posts to the same -// /forms/rsvp the form uses, so there is no second public write endpoint. +// The links select an answer; an explicit confirmation on the landing page +// submits it. Merely opening an email link must never change an RSVP. // {{email}} is a campaign merge field and resolves per recipient. -function answerButtons() { - var yes = SITE + "/?a=yes&e={{email}}#rsvp"; - var no = SITE + "/?a=no&e={{email}}#rsvp"; +function answerButtons(date) { + var yes = SITE + "/?a=yes&e={{email}}&d=" + encodeURIComponent(date) + "#rsvp"; + var no = SITE + "/?a=no&e={{email}}&d=" + encodeURIComponent(date) + "#rsvp"; return '' + '' + '' + '
' + @@ -151,7 +151,7 @@ function answerButtons() { 'Not this week' + '
' + - '

One tap, nothing to fill in. Either answer keeps you on the list for future weeks.

'; + '

Confirm your answer on the page that opens. Either answer keeps you on the list for future weeks.

'; } // ── main ──────────────────────────────────────────────────────────────── @@ -168,7 +168,26 @@ var target = addDays(now.iso, (WEEKDAY - now.day + 7) % 7 || 7); if (CANCELLED.indexOf(target) !== -1) return { skipped: "next Break is cancelled: " + target }; var marker = "founders-break:reminder:" + target; -if (!force && (await kv.get(marker))) return { skipped: "already sent for " + target }; +var previousMarker = await kv.get(marker); +var postalAddress = String(env.POSTAL_ADDRESS || "").trim(); +if (!postalAddress) throw new Error("POSTAL_ADDRESS must be configured before creating a reminder"); + +// Reuse a draft after failure. A marker records preparation, not successful +// queueing. Consult the campaign and its durable recipients before any retry. +var campaigns = await tool("list_campaigns", {}); +var campaignRows = Array.isArray(campaigns) ? campaigns : (campaigns && campaigns.result) || []; +var existing = campaignRows.find(function (c) { return c.name === "Founder's Break reminder " + target; }); +if (existing && ["sent", "sending", "scheduled", "paused"].indexOf(existing.status) !== -1) { + return { skipped: "campaign already queued, sent or paused", event_date: target, campaign_id: existing.id }; +} +if (existing) { + if (existing.status !== "draft") return { skipped: "campaign requires review", campaign_id: existing.id }; + var recipients = await tool("get_campaign_stats", { campaign_id: existing.id }); + var recipientRows = Array.isArray(recipients) ? recipients : (recipients && recipients.result) || []; + if (recipientRows.length) return { skipped: "delivery queue already exists; review required", campaign_id: existing.id }; +} else if (previousMarker) { + return { skipped: "marker without campaign; review required", event_date: target }; +} var when = pretty(target); @@ -176,9 +195,9 @@ var body = '

The Break is tomorrow night. Same place, same time.

' + marquee(when) + '

Bring a valid ID and whatever you are working on.

' + - answerButtons(); + answerButtons(target); -var campaign = await tool("create_campaign", { +var campaign = existing || await tool("create_campaign", { name: "Founder's Break reminder " + target, subject: "Tomorrow: The Founder's Break, " + when, list_id: env.LIST_ID, @@ -201,11 +220,15 @@ var campaign = await tool("create_campaign", { var campaignId = campaign && (campaign.id || (campaign.result && campaign.result.id)); if (!campaignId) throw new Error("create_campaign returned no id: " + JSON.stringify(campaign).slice(0, 200)); -// Mark before sending. A crash after this leaves the week unsent, which is a -// far better failure than mailing the whole list twice. -await kv.set(marker, new Date().toISOString()); - -await tool("send_campaign", { id: campaignId }); -log("sent reminder for " + target); +await kv.set(marker, { state: "prepared", campaign_id: campaignId, event_date: target }); +var queued = await tool("send_campaign", { id: campaignId, delivery: { + postal_address: postalAddress, + sender_name: "The Founder's Break | OneTouch Studio", + timezone: TZ, interval_min_seconds: 60, interval_max_seconds: 60, + window_start_hour: 9, window_end_hour: 21, daily_cap: 50, +} }); +if (!queued || queued.ok !== true) throw new Error("Campaign queueing did not report success"); +await kv.set(marker, { state: "queued", campaign_id: campaignId, event_date: target, queued_at: new Date().toISOString() }); +log("queued reminder for " + target); return { ok: true, event_date: target, campaign_id: campaignId }; diff --git a/ops/founders-break-rsvp.js b/ops/founders-break-rsvp.js index 15e14d5..eddb48b 100644 --- a/ops/founders-break-rsvp.js +++ b/ops/founders-break-rsvp.js @@ -125,6 +125,13 @@ var eventDate = String(data.event_date || "").trim(); var attending = String(data.attending || "yes").trim().toLowerCase(); var viaEmail = String(data.source || "") === "reminder-email"; +// Email link previews must never change an answer or send notifications. +// The landing page adds this only after its explicit confirmation button. +if (viaEmail && String(data.rsvp_confirmed || "") !== "yes") { + return { skipped: "confirmation required" }; +} +if (attending !== "yes" && attending !== "no") return { skipped: "invalid answer" }; + // Honeypot: a real browser never fills this. if (String(data.company_website || "").trim()) return { skipped: "honeypot" }; if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return { skipped: "no usable email" }; @@ -137,6 +144,8 @@ var returning = !!(await kv.get(seenKey)); var answerKey = "founders-break:answer:" + (eventDate || "next") + ":" + email.toLowerCase(); var previous = await kv.get(answerKey); var changed = previous !== attending; +if (!changed) return { ok: true, event_date: eventDate, attending: attending, + previous: previous, changed: false, confirmation: "skipped (already confirmed)", via_email: viaEmail }; await kv.set(answerKey, attending); var first = name.split(/\s+/)[0] || "there"; @@ -289,7 +298,7 @@ var notifyBody = line("Coming to", when) + line("Answer", (attending === "no" ? "NOT coming this week" : "coming") + (previous && changed ? " (changed from " + esc(previous) + ")" : "")) + - line("Came from", viaEmail ? "one-click in the reminder email" : "the site form") + + line("Came from", viaEmail ? "confirmed reminder-email response" : "the site form") + line("Mailing list", listed ? "added" : "not added, check the logs") + '

Confirmation sent: ' + mailed + '.

'; diff --git a/ops/regression.test.cjs b/ops/regression.test.cjs new file mode 100644 index 0000000..b866436 --- /dev/null +++ b/ops/regression.test.cjs @@ -0,0 +1,160 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = process.env.FOUNDERS_SOURCE_ROOT || path.resolve(__dirname, '..'); +const source = file => fs.readFileSync(path.join(root, file), 'utf8'); +const tick = () => new Promise(resolve => setImmediate(resolve)); + +function page(query, response = { ok: true }) { + const nodes = new Map(), requests = []; + class Element { + constructor(id) { this.id = id; this.listeners = {}; this.children = []; this.value = ''; this.hidden = false; + this.style = {}; this.dataset = {}; this.classList = { add() {}, toggle() {}, contains() { return false; } }; } + setAttribute() {} removeAttribute() {} focus() {} prepend() {} + appendChild(child) { this.children.push(child); if (child.id) nodes.set(child.id, child); return child; } + insertBefore(child) { this.appendChild(child); } + addEventListener(name, fn) { this.listeners[name] = fn; } + querySelector(selector) { + if (selector === '[name="event_date"]') return { value: '2026-10-22' }; + return new Element(); + } + querySelectorAll() { return []; } + closest() { return new Element(); } + } + for (const id of ['nav', 'rsvp', 'rsvp-form', 'rsvp-submit', 'form-status', 'rsvp-done', 'rsvp-live', 'f-name', 'f-email']) nodes.set(id, new Element(id)); + nodes.get('rsvp-form').action = 'https://qa.invalid/forms/rsvp'; + nodes.get('rsvp-done').hidden = true; + const document = { body: new Element(), currentScript: null, + getElementById: id => nodes.get(id) || null, querySelectorAll: () => [], querySelector: () => null, + createElement: () => new Element() }; + const location = { search: query, pathname: '/' }; + const window = { matchMedia: () => ({ matches: true }), location, history: { replaceState() {} } }; + const fetch = async (url, options) => { requests.push({ url, options }); return { ok: true, json: async () => response }; }; + vm.runInNewContext(source('public/js/main.js'), { document, window, location, URLSearchParams, Intl, Date, + IntersectionObserver: class { observe() {} disconnect() {} }, fetch, setTimeout }); + return { nodes, requests, click: (choice, isTrusted = true) => nodes.get('confirm-rsvp-' + choice)?.listeners.click({ isTrusted }) }; +} + +for (const choice of ['yes', 'no']) test(`opening a legacy ${choice} link never submits`, () => { + const p = page(`?a=${choice}&e=qa%40example.invalid`); + assert.equal(p.requests.length, 0); +}); +test('dated link needs a trusted click and submits only once', async () => { + const p = page('?a=yes&e=qa%2Btest%40example.invalid&d=2026-10-22'); + assert.equal(p.requests.length, 0); + assert.ok(p.nodes.has('confirm-rsvp-yes')); + p.click('yes', false); assert.equal(p.requests.length, 0); + p.click('yes'); p.click('no'); + await tick(); + assert.equal(p.requests.length, 1); + const body = new URLSearchParams(p.requests[0].options.body); + assert.equal(body.get('email'), 'qa+test@example.invalid'); + assert.equal(body.get('event_date'), '2026-10-22'); + assert.equal(body.get('attending'), 'yes'); + assert.equal(body.get('rsvp_confirmed'), 'yes'); + assert.equal(p.nodes.get('rsvp-done').hidden, false); +}); +test('a deliberate no is preserved', async () => { + const p = page('?a=no&e=qa%40example.invalid&d=2026-10-22'); + p.click('no'); await tick(); + assert.equal(new URLSearchParams(p.requests[0].options.body).get('attending'), 'no'); +}); +test('a stale dated invitation cannot silently answer for another week', () => { + const p = page('?a=no&e=qa%40example.invalid&d=2026-10-15'); + assert.equal(p.requests.length, 0); + assert.equal(p.nodes.has('confirm-rsvp-no'), false); + assert.match(p.nodes.get('form-status').textContent, /different week/); +}); +test('an invalid address keeps the ordinary form available', () => { + const p = page('?a=yes&e=invalid'); + assert.equal(p.requests.length, 0); + assert.equal(p.nodes.get('rsvp-form').hidden, false); +}); +test('a rejected save leaves confirmation available for retry', async () => { + const p = page('?a=yes&e=qa%40example.invalid', { ok: false }); + p.click('yes'); await tick(); + assert.equal(p.nodes.get('confirm-rsvp-yes').disabled, false); + assert.equal(p.nodes.get('rsvp-done').hidden, true); + assert.equal(p.nodes.get('rsvp-live').hidden, false); +}); + +function functionFixture(file, { now = '2026-10-28T18:00:00Z', event = {}, initial = [], campaigns = [], recipients = [] } = {}) { + const writes = [], calls = [], store = new Map(initial); + let failSend = false; + class FixedDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return Date.parse(now); } } + const fn = vm.runInNewContext(`(async function(ctx) { ${source('ops/' + file)}\n})`, { Date: FixedDate, Intl, URLSearchParams }); + const env = { POSTAL_ADDRESS: 'Synthetic QA postal fixture, not a production address', LIST_ID: 'qa-list', FROM_ADDRESS_ID: 'qa-sender', NOTIFY: 'qa@example.invalid', FROM: 'qa@example.invalid', CORBEN_KEY: 'synthetic-key' }; + const ctx = { event, env, log() {}, kv: { get: async key => store.get(key), set: async (key, value) => { writes.push({ key, value }); store.set(key, value); } }, + fetch: async (_url, options) => { + const request = JSON.parse(options.body); calls.push(request); + if (request.tool === 'send_campaign' && failSend) return { ok: false, status: 500, text: 'synthetic queue failure' }; + let data; + if (request.tool === 'list_campaigns') data = campaigns; + else if (request.tool === 'get_campaign_stats') data = recipients; + else if (request.tool === 'create_campaign') { data = { id: 'qa-campaign', ...request.arguments, status: 'draft' }; campaigns.push(data); } + else if (request.tool === 'send_campaign') { data = { ok: true, queued: 2 }; campaigns.find(c => c.id === request.arguments.id).status = 'sending'; } + else if (request.tool === 'list_contacts') data = [{ id: 'qa-contact', email: 'qa@example.invalid', tags: ['founders-break'] }]; + else data = { id: 'synthetic-id', ok: true }; + return { ok: true, status: 200, json: data }; + } }; + return { env, calls, writes, store, run: () => fn(ctx), failSend: value => { failSend = value; } }; +} +const answerData = { source: 'reminder-email', name: 'Synthetic QA', email: 'qa@example.invalid', event_date: '2026-10-22', attending: 'yes' }; +for (const attending of ['yes', 'no']) test(`backend rejects unconfirmed ${attending} without a write or send`, async () => { + const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, attending } } }); + assert.equal((await f.run()).skipped, 'confirmation required'); + assert.equal(f.writes.length, 0); assert.equal(f.calls.length, 0); +}); +test('repeated confirmed answer sends no duplicate notification', async () => { + const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, rsvp_confirmed: 'yes' } }, initial: [['founders-break:answer:2026-10-22:qa@example.invalid', 'yes']] }); + assert.equal((await f.run()).changed, false); assert.equal(f.calls.length, 0); assert.equal(f.writes.length, 0); +}); +test('a confirmed new answer still reaches the existing RSVP pipeline', async () => { + const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, rsvp_confirmed: 'yes' } } }); + assert.equal((await f.run()).attending, 'yes'); + assert.equal(f.calls.filter(c => c.tool === 'send_email').length, 2); +}); +test('ordinary site registration still works', async () => { + const f = functionFixture('founders-break-rsvp.js', { event: { data: { ...answerData, source: undefined } } }); + assert.equal((await f.run()).ok, true); +}); + +test('weekly queue request includes postal configuration and dated links', async () => { + const f = functionFixture('founders-break-reminder.js'); await f.run(); + const send = f.calls.find(c => c.tool === 'send_campaign'); + assert.equal(send.arguments.delivery.postal_address, f.env.POSTAL_ADDRESS); + assert.equal(send.arguments.delivery.timezone, 'America/New_York'); + assert.match(f.calls.find(c => c.tool === 'create_campaign').arguments.html_body, /&d=2026-10-29/); + assert.equal(f.store.get('founders-break:reminder:2026-10-29').state, 'queued'); +}); +test('failed send retains a prepared draft and a later run reuses it', async () => { + const f = functionFixture('founders-break-reminder.js'); f.failSend(true); + await assert.rejects(f.run(), /send_campaign failed/); + assert.equal(f.store.get('founders-break:reminder:2026-10-29').state, 'prepared'); + f.failSend(false); await f.run(); + assert.equal(f.calls.filter(c => c.tool === 'create_campaign').length, 1); + assert.equal(f.store.get('founders-break:reminder:2026-10-29').state, 'queued'); +}); +test('a legacy failure marker does not block reuse of an unsent draft', async () => { + const f = functionFixture('founders-break-reminder.js', { initial: [['founders-break:reminder:2026-10-29', 'legacy-timestamp']], campaigns: [{ id: 'draft-id', name: "Founder's Break reminder 2026-10-29", status: 'draft' }] }); + await f.run(); assert.equal(f.calls.filter(c => c.tool === 'create_campaign').length, 0); + assert.equal(f.calls.find(c => c.tool === 'send_campaign').arguments.id, 'draft-id'); +}); +test('queued campaigns and draft campaigns with recipient queues are not sent again', async () => { + for (const state of ['sending', 'sent', 'paused', 'scheduled', 'draft']) { + const f = functionFixture('founders-break-reminder.js', { campaigns: [{ id: 'existing-id', name: "Founder's Break reminder 2026-10-29", status: state }], recipients: [{}] }); + await f.run(); assert.equal(f.calls.filter(c => c.tool === 'send_campaign').length, 0); + } +}); +test('missing postal address fails before campaign creation', async () => { + const f = functionFixture('founders-break-reminder.js'); f.env.POSTAL_ADDRESS = ''; + await assert.rejects(f.run(), /POSTAL_ADDRESS/); + assert.equal(f.calls.length, 0); +}); +test('Thursday remains unsent without running a catch-up campaign', async () => { + const f = functionFixture('founders-break-reminder.js', { now: '2026-10-22T18:00:00Z' }); + assert.match((await f.run()).skipped, /not wednesday/); + assert.equal(f.calls.length, 0); +}); diff --git a/public/js/main.js b/public/js/main.js index cdae021..d7b46e2 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -287,46 +287,78 @@ var params = new URLSearchParams(window.location.search); if (params.get("rsvp") === "ok") showDone(); - /* One-click answer from the weekly reminder: ?a=yes|no&e=
. - This posts to the same /forms/rsvp the form uses rather than opening a new - public write endpoint, so there is exactly one path into the pipeline. */ + /* Email links select an answer, but only a trusted confirmation click + submits it. Security scanners can open both links without changing RSVPs. */ var answer = params.get("a"); if (answer === "yes" || answer === "no") { - // URLSearchParams turns "+" into a space, and an address can never contain - // a literal space, so putting it back is safe and fixes plus-addressing. var who = (params.get("e") || "").trim().replace(/ /g, "+"); + var currentEvent = form.querySelector('[name="event_date"]').value; + var linkedEvent = params.get("d"); if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(who)) { - // Bad or missing address: fall back to the normal form rather than guess. status.textContent = "We could not read that link. Fill this in and you are set."; + } else if (linkedEvent && linkedEvent !== currentEvent) { + status.textContent = "That invitation is for a different week. Use this form to RSVP for the date shown above."; } else { emailField.value = who; - submit.setAttribute("aria-busy", "true"); - var oneClick = new URLSearchParams(); - oneClick.set("name", params.get("n") || who.split("@")[0]); - oneClick.set("email", who); - oneClick.set("attending", answer); - oneClick.set("source", "reminder-email"); - oneClick.set("event_date", form.querySelector('[name="event_date"]').value); - - fetch(form.action, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "application/json", - }, - body: oneClick.toString(), - }) - .then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json().catch(function () { return {}; }); }) - .then(function (body) { - if (body && body.ok === false) throw new Error("rejected"); - track("rsvp_completed", { via: "reminder-email", answer: answer }); - showDone(answer); - }) - .catch(function (err) { - track("rsvp_failed", { via: "reminder-email", answer: answer, reason: String(err && err.message || "unknown").slice(0, 80) }); - submit.removeAttribute("aria-busy"); - status.textContent = "That link did not go through. Hit RSVP below and you are set."; + form.hidden = true; + var panel = document.createElement("div"); + panel.id = "email-rsvp-confirm"; + var message = document.createElement("p"); + var prettyEvent = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", weekday: "long", month: "long", day: "numeric", + }).format(new Date(currentEvent + "T12:00:00Z")); + message.textContent = "Confirm your RSVP for " + prettyEvent + " as " + who + ". Nothing is saved until you confirm."; + panel.appendChild(message); + var feedback = document.createElement("p"); + feedback.setAttribute("role", "status"); + feedback.setAttribute("aria-live", "polite"); + var buttons = []; + var pending = false; + [answer, answer === "yes" ? "no" : "yes"].forEach(function (choice, i) { + var button = document.createElement("button"); + button.type = "button"; + button.id = "confirm-rsvp-" + choice; + button.className = "btn btn-block" + (i === 0 ? " btn-primary" : ""); + button.textContent = choice === "yes" ? "Confirm: I am coming" : "Confirm: I cannot make it"; + button.addEventListener("click", function (event) { + if (!event.isTrusted || pending) return; + pending = true; + feedback.textContent = "Saving your answer..."; + buttons.forEach(function (b) { b.disabled = true; }); + var payload = new URLSearchParams(); + payload.set("name", params.get("n") || who.split("@")[0]); + payload.set("email", who); + payload.set("attending", choice); + payload.set("source", "reminder-email"); + payload.set("rsvp_confirmed", "yes"); + payload.set("event_date", currentEvent); + fetch(form.action, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", Accept: "application/json" }, + body: payload.toString(), + }) + .then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); }) + .then(function (body) { + if (!body || body.ok !== true) throw new Error("rejected"); + track("rsvp_completed", { via: "reminder-email", answer: choice }); + ["a", "e", "n", "d"].forEach(function (key) { params.delete(key); }); + var query = params.toString(); + window.history.replaceState(null, "", window.location.pathname + (query ? "?" + query : "") + "#rsvp"); + showDone(choice); + }) + .catch(function (err) { + pending = false; + buttons.forEach(function (b) { b.disabled = false; }); + feedback.textContent = "That did not go through. Please try confirming again."; + track("rsvp_failed", { via: "reminder-email", answer: choice, reason: String(err && err.message || "unknown").slice(0, 80) }); + }); }); + buttons.push(button); + panel.appendChild(button); + }); + panel.appendChild(feedback); + live.insertBefore(panel, form); } } + })(); diff --git a/views/index.ejs b/views/index.ejs index 381cdbf..b91ac85 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -352,6 +352,6 @@ - +