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,
|
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.
|
in these files. Never paste the key into this repo.
|
||||||
|
|
||||||
Test without waiting for a real submission:
|
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=3d3c4bed-... event={"form_name":"rsvp","data":{"name":"...","email":"..."}}
|
||||||
execute_function id=cf2a1e4c-... event={"force":true} # sends for real
|
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
|
// 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.
|
// 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.
|
// 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;
|
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
|
// The links select an answer; an explicit confirmation on the landing page
|
||||||
// /forms/rsvp the form uses, so there is no second public write endpoint.
|
// submits it. Merely opening an email link must never change an RSVP.
|
||||||
// {{email}} is a campaign merge field and resolves per recipient.
|
// {{email}} is a campaign merge field and resolves per recipient.
|
||||||
function answerButtons() {
|
function answerButtons(date) {
|
||||||
var yes = SITE + "/?a=yes&e={{email}}#rsvp";
|
var yes = SITE + "/?a=yes&e={{email}}&d=" + encodeURIComponent(date) + "#rsvp";
|
||||||
var no = SITE + "/?a=no&e={{email}}#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;">' +
|
return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">' +
|
||||||
'<tr>' +
|
'<tr>' +
|
||||||
'<td style="border-radius:10px;background:#10b981;">' +
|
'<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>' +
|
'<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>' +
|
'</td>' +
|
||||||
'</tr></table>' +
|
'</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 ────────────────────────────────────────────────────────────────
|
// ── 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 };
|
if (CANCELLED.indexOf(target) !== -1) return { skipped: "next Break is cancelled: " + target };
|
||||||
|
|
||||||
var marker = "founders-break:reminder:" + 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);
|
var when = pretty(target);
|
||||||
|
|
||||||
|
|
@ -176,9 +195,9 @@ var body =
|
||||||
'<p style="margin:0;">The Break is tomorrow night. Same place, same time.</p>' +
|
'<p style="margin:0;">The Break is tomorrow night. Same place, same time.</p>' +
|
||||||
marquee(when) +
|
marquee(when) +
|
||||||
'<p style="margin:20px 0 0;">Bring a valid ID and whatever you are working on.</p>' +
|
'<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,
|
name: "Founder's Break reminder " + target,
|
||||||
subject: "Tomorrow: The Founder's Break, " + when,
|
subject: "Tomorrow: The Founder's Break, " + when,
|
||||||
list_id: env.LIST_ID,
|
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));
|
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));
|
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
|
await kv.set(marker, { state: "prepared", campaign_id: campaignId, event_date: target });
|
||||||
// far better failure than mailing the whole list twice.
|
var queued = await tool("send_campaign", { id: campaignId, delivery: {
|
||||||
await kv.set(marker, new Date().toISOString());
|
postal_address: postalAddress,
|
||||||
|
sender_name: "The Founder's Break | OneTouch Studio",
|
||||||
await tool("send_campaign", { id: campaignId });
|
timezone: TZ, interval_min_seconds: 60, interval_max_seconds: 60,
|
||||||
log("sent reminder for " + target);
|
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 };
|
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 attending = String(data.attending || "yes").trim().toLowerCase();
|
||||||
var viaEmail = String(data.source || "") === "reminder-email";
|
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.
|
// Honeypot: a real browser never fills this.
|
||||||
if (String(data.company_website || "").trim()) return { skipped: "honeypot" };
|
if (String(data.company_website || "").trim()) return { skipped: "honeypot" };
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return { skipped: "no usable email" };
|
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 answerKey = "founders-break:answer:" + (eventDate || "next") + ":" + email.toLowerCase();
|
||||||
var previous = await kv.get(answerKey);
|
var previous = await kv.get(answerKey);
|
||||||
var changed = previous !== attending;
|
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);
|
await kv.set(answerKey, attending);
|
||||||
|
|
||||||
var first = name.split(/\s+/)[0] || "there";
|
var first = name.split(/\s+/)[0] || "there";
|
||||||
|
|
@ -289,7 +298,7 @@ var notifyBody =
|
||||||
line("Coming to", when) +
|
line("Coming to", when) +
|
||||||
line("Answer", (attending === "no" ? "NOT coming this week" : "coming") +
|
line("Answer", (attending === "no" ? "NOT coming this week" : "coming") +
|
||||||
(previous && changed ? " (changed from " + esc(previous) + ")" : "")) +
|
(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") +
|
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>';
|
'<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);
|
var params = new URLSearchParams(window.location.search);
|
||||||
if (params.get("rsvp") === "ok") showDone();
|
if (params.get("rsvp") === "ok") showDone();
|
||||||
|
|
||||||
/* One-click answer from the weekly reminder: ?a=yes|no&e=<address>.
|
/* Email links select an answer, but only a trusted confirmation click
|
||||||
This posts to the same /forms/rsvp the form uses rather than opening a new
|
submits it. Security scanners can open both links without changing RSVPs. */
|
||||||
public write endpoint, so there is exactly one path into the pipeline. */
|
|
||||||
var answer = params.get("a");
|
var answer = params.get("a");
|
||||||
if (answer === "yes" || answer === "no") {
|
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 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)) {
|
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.";
|
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 {
|
} else {
|
||||||
emailField.value = who;
|
emailField.value = who;
|
||||||
submit.setAttribute("aria-busy", "true");
|
form.hidden = true;
|
||||||
var oneClick = new URLSearchParams();
|
var panel = document.createElement("div");
|
||||||
oneClick.set("name", params.get("n") || who.split("@")[0]);
|
panel.id = "email-rsvp-confirm";
|
||||||
oneClick.set("email", who);
|
var message = document.createElement("p");
|
||||||
oneClick.set("attending", answer);
|
var prettyEvent = new Intl.DateTimeFormat("en-US", {
|
||||||
oneClick.set("source", "reminder-email");
|
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
|
||||||
oneClick.set("event_date", form.querySelector('[name="event_date"]').value);
|
}).format(new Date(currentEvent + "T12:00:00Z"));
|
||||||
|
message.textContent = "Confirm your RSVP for " + prettyEvent + " as " + who + ". Nothing is saved until you confirm.";
|
||||||
fetch(form.action, {
|
panel.appendChild(message);
|
||||||
method: "POST",
|
var feedback = document.createElement("p");
|
||||||
headers: {
|
feedback.setAttribute("role", "status");
|
||||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
feedback.setAttribute("aria-live", "polite");
|
||||||
Accept: "application/json",
|
var buttons = [];
|
||||||
},
|
var pending = false;
|
||||||
body: oneClick.toString(),
|
[answer, answer === "yes" ? "no" : "yes"].forEach(function (choice, i) {
|
||||||
})
|
var button = document.createElement("button");
|
||||||
.then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json().catch(function () { return {}; }); })
|
button.type = "button";
|
||||||
.then(function (body) {
|
button.id = "confirm-rsvp-" + choice;
|
||||||
if (body && body.ok === false) throw new Error("rejected");
|
button.className = "btn btn-block" + (i === 0 ? " btn-primary" : "");
|
||||||
track("rsvp_completed", { via: "reminder-email", answer: answer });
|
button.textContent = choice === "yes" ? "Confirm: I am coming" : "Confirm: I cannot make it";
|
||||||
showDone(answer);
|
button.addEventListener("click", function (event) {
|
||||||
})
|
if (!event.isTrusted || pending) return;
|
||||||
.catch(function (err) {
|
pending = true;
|
||||||
track("rsvp_failed", { via: "reminder-email", answer: answer, reason: String(err && err.message || "unknown").slice(0, 80) });
|
feedback.textContent = "Saving your answer...";
|
||||||
submit.removeAttribute("aria-busy");
|
buttons.forEach(function (b) { b.disabled = true; });
|
||||||
status.textContent = "That link did not go through. Hit RSVP below and you are set.";
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,6 @@
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue