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
This commit is contained in:
parent
8cfa141648
commit
3572291933
7 changed files with 317 additions and 51 deletions
|
|
@ -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.
|
||||
|
|
|
|||
24
ops/REPAIR_2026-10-22.md
Normal file
24
ops/REPAIR_2026-10-22.md
Normal file
|
|
@ -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/`.
|
||||
|
|
@ -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 '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">' +
|
||||
'<tr>' +
|
||||
'<td style="border-radius:10px;background:#10b981;">' +
|
||||
|
|
@ -151,7 +151,7 @@ function answerButtons() {
|
|||
'<a href="' + no + '" style="display:inline-block;padding:12px 26px;font:600 15px/1 ' + SANS + ';color:#c2c0d4;text-decoration:none;border-radius:10px;">Not this week</a>' +
|
||||
'</td>' +
|
||||
'</tr></table>' +
|
||||
'<p style="margin:12px 0 0;font:400 13px/1.5 ' + SANS + ';color:#6f6d88;">One tap, nothing to fill in. Either answer keeps you on the list for future weeks.</p>';
|
||||
'<p style="margin:12px 0 0;font:400 13px/1.5 ' + SANS + ';color:#6f6d88;">Confirm your answer on the page that opens. Either answer keeps you on the list for future weeks.</p>';
|
||||
}
|
||||
|
||||
// ── 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 =
|
|||
'<p style="margin:0;">The Break is tomorrow night. Same place, same time.</p>' +
|
||||
marquee(when) +
|
||||
'<p style="margin:20px 0 0;">Bring a valid ID and whatever you are working on.</p>' +
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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") +
|
||||
'<p style="margin:18px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Confirmation sent: ' + mailed + '.</p>';
|
||||
|
||||
|
|
|
|||
160
ops/regression.test.cjs
Normal file
160
ops/regression.test.cjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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=<address>.
|
||||
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);
|
||||
|
||||
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: oneClick.toString(),
|
||||
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().catch(function () { return {}; }); })
|
||||
.then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); })
|
||||
.then(function (body) {
|
||||
if (body && body.ok === false) throw new Error("rejected");
|
||||
track("rsvp_completed", { via: "reminder-email", answer: answer });
|
||||
showDone(answer);
|
||||
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) {
|
||||
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.";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -352,6 +352,6 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/js/main.js" data-site="<%= tenant.id %>" defer></script>
|
||||
<script src="/js/main.js?v=20261022-confirm" data-site="<%= tenant.id %>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in a new issue