Check in the RSVP and reminder function sources, document the whole flow

This commit is contained in:
Devin Willis 2026-08-26 14:52:51 -04:00
parent 8ec3f51792
commit 149a6eaa71
No known key found for this signature in database
GPG key ID: 3776B3C5AFF3FFEA
4 changed files with 481 additions and 19 deletions

View file

@ -9,17 +9,17 @@ Corben EJS site. There is no build step: `deploy_site` copies this repo verbatim
site.config.js all schedule, venue and copy data site.config.js all schedule, venue and copy data
views/index.ejs the single page, including the next-date logic views/index.ejs the single page, including the next-date logic
public/css/style.css brand tokens lifted from onetouch.studio public/css/style.css brand tokens lifted from onetouch.studio
public/js/main.js nav state, scroll reveal, RSVP submit public/js/main.js nav state, scroll reveal, live countdown, RSVP submit
public/assets/ the recurring .ics public/assets/ the recurring .ics and the print-resolution QR
public/images/ logo and favicon public/images/ logo, favicon, RSVP QR
``` ```
## The date is never hard-coded ## The date is never hard-coded
`views/index.ejs` computes the next occurrence at render time from `views/index.ejs` computes the next occurrence at render time from `weekday` +
`weekday` + `firstEvent` in `site.config.js`, so the page is correct on every `firstEvent` in `site.config.js`. `public/js/main.js` runs the same rule in the
load without anyone touching it. After 9:00 PM on an event night it rolls to browser, so the countdown ticks and rolls itself over to the following Thursday
the following week. once the night ends, with no reload and no redeploy.
## Cancelling a week ## Cancelling a week
@ -29,24 +29,54 @@ Add the ISO date to `cancelled` in `site.config.js` and redeploy:
cancelled: ["2026-11-26"], // Thanksgiving cancelled: ["2026-11-26"], // Thanksgiving
``` ```
The page then skips that Thursday, shows a notice naming the cancelled date, The page then skips that Thursday, shows a notice naming the cancelled date, and
and points at the next one that is actually happening. The `.ics` in points at the next one that is actually happening. Two other places do **not**
`public/assets/` is a plain weekly recurrence and does not know about read this list and need the same date added by hand:
cancellations, so send the newsletter a heads-up too.
- `public/assets/founders-break.ics` is a plain weekly recurrence.
- the `CANCELLED` array in the `founders-break-reminder` Corben function, or it
will email everyone about a Break that is not happening.
## Deploying ## Deploying
``` ```
bulk_upload_git owner=gator repo=founders-break
deploy_site id=4ae63819-3f63-4fea-b0de-eb1e5befe607 repo=gator/founders-break mode=ejs deploy_site id=4ae63819-3f63-4fea-b0de-eb1e5befe607 repo=gator/founders-break mode=ejs
``` ```
Nothing redeploys itself. Every edit needs the deploy step. Nothing redeploys itself. Every edit needs the deploy step.
## RSVPs ## What happens when somebody RSVPs
The form POSTs to `/forms/rsvp`, which the Corben sites renderer stores in The form POSTs to `/forms/rsvp`. The Corben sites renderer stores it in
`site_form_submissions` and announces as a `form.submitted` event. Read them `site_form_submissions` and fires a `form.submitted` event. From there it is all
with `get_site_form_submissions`. JavaScript submits it in place; without plain JavaScript, deliberately: there is no LLM anywhere in the send path.
JavaScript the plain POST falls back to `/?rsvp=ok` and the page shows the
same confirmation. | Piece | Id |
|---|---|
| Trigger `Founder's Break RSVP` (form.submitted, form_name=rsvp) | `e035fb68-2ecd-452b-bf74-a051c2775423` |
| Function `founders-break-rsvp` | `3d3c4bed-c073-40a4-9a32-9ff6944737dd` |
| Function `founders-break-reminder` | `cf2a1e4c-43f9-4cf4-8fc5-652c494940ae` |
| Cron `Founder's Break weekly reminder` (every 6h) | `84efd815-5426-4952-af88-2f4ed81efc54` |
| Email list `Founder's Break` | `09429866-0005-4260-bf56-2d520eea4db7` |
`founders-break-rsvp` resolves a CRM contact, writes the email address back onto
it (`create_contact` dedupes server-side and can return a record with no address,
which would put an unmailable member on the list), adds them to the list, sends
the HTML confirmation, and sends an internal note to gatordevin@gmail.com.
`founders-break-reminder` runs every 6 hours, works out the Eastern weekday
itself, and only sends on Wednesday between 09:00 and 20:00 ET. A `kv` marker
keyed to the event date makes any repeat run a no-op, which is what makes it safe
to run four times a day.
Both functions authenticate with a scoped `email + crm` key held by the paused
**Founders Break** agent record (`666f7ad0-f546-4fc6-a3c4-14f1384e91a5`). That
agent runs nothing. It exists only because an API key cannot mint another API
key. Deleting it revokes the key and breaks both functions.
## The QR code
`public/images/rsvp-qr.png` and `public/assets/rsvp-qr.svg` both encode
`https://foundersbreak.onetouch.studio/#rsvp`. Deliberately dark-on-white:
inverted QR codes are unreliable on older scanners and these go on flyers.
Regenerate with the `qrcode` npm package if the URL ever changes.

26
ops/README.md Normal file
View file

@ -0,0 +1,26 @@
# Corben function sources
These two files are the source of truth for the Corben functions behind the RSVP
flow. They do NOT deploy with the site: `deploy_site` only serves the site, and
Corben functions live in the platform, not in this repo. They are checked in so
the logic is reviewable and diffable instead of only existing inside the API.
To change one, edit here and push it up:
```bash
KEY=$(grep '^CORBEN_API_KEY=' ~/.corben.env | cut -d= -f2-)
jq -n --rawfile code ops/founders-break-rsvp.js '{code:$code}' \
| curl -sS -X PUT https://api.corben.world/functions/3d3c4bed-c073-40a4-9a32-9ff6944737dd \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d @-
```
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
in these files. Never paste the key into this repo.
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
```

View file

@ -0,0 +1,173 @@
// The Founder's Break - weekly reminder.
//
// Runs on a plain interval (the cron runner has no day-of-week syntax), works
// out the Eastern weekday itself, and only sends on Wednesday. Deterministic on
// purpose: no LLM anywhere in the send path. A kv marker keyed to the event date
// 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
// 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 API = "https://api.corben.world/mcp";
var TZ = "America/New_York";
var VENUE = "Silver Q Billiards & Sports Bar";
var STREET = "225 W University Ave";
var CITY = "Gainesville, FL 32601";
var SITE = "https://foundersbreak.onetouch.studio";
var MAP = "https://www.google.com/maps/search/?api=1&query=Silver+Q+Billiards+225+W+University+Ave+Gainesville+FL+32601";
var WEEKDAY = 4; // Thursday
var SEND_DAY = 3; // Wednesday
var CANCELLED = []; // ISO dates to skip; keep in step with site.config.js
async function tool(name, args) {
var res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + env.CORBEN_KEY },
body: JSON.stringify({ tool: name, arguments: args }),
});
if (!res.ok) throw new Error(name + " failed: " + res.status + " " + String(res.text).slice(0, 200));
return res.json;
}
function etNow() {
var now = new Date();
var iso = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit",
}).format(now);
var wd = new Intl.DateTimeFormat("en-US", { timeZone: TZ, weekday: "short" }).format(now);
var hour = Number(new Intl.DateTimeFormat("en-US", { timeZone: TZ, hour: "2-digit", hourCycle: "h23" }).format(now)) % 24;
var order = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
return { iso: iso, day: order[wd], hour: hour };
}
function addDays(iso, n) {
var p = iso.split("-").map(Number);
var d = new Date(Date.UTC(p[0], p[1] - 1, p[2]));
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function pretty(iso) {
var p = iso.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
}).format(new Date(Date.UTC(p[0], p[1] - 1, p[2])));
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
var HERO = "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/e7119448-83ba-4da4-95d8-b64045c21160/email-header-trim.png";
var SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// One shell for every outbound email so they all look like the site. `image`
// is optional: the internal notification does not need a photograph.
function shell(opts) {
var image = opts.image
? '<tr><td style="padding:0;"><img src="' + opts.image + '" width="560" alt="' + esc(opts.imageAlt || "") + '" ' +
'style="display:block;width:100%;max-width:560px;height:auto;border-radius:14px 14px 0 0;border:0;"></td></tr>'
: "";
return '<!DOCTYPE html><html><head><meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<meta name="color-scheme" content="dark light">' +
'<meta name="supported-color-schemes" content="dark light"></head>' +
'<body style="margin:0;padding:0;background:#06060b;">' +
'<div style="display:none;max-height:0;overflow:hidden;opacity:0;">' + esc(opts.preview || "") + '</div>' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#06060b;padding:32px 16px;">' +
'<tr><td align="center">' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#0c0c14;border:1px solid rgba(16,185,129,0.18);border-radius:14px;overflow:hidden;">' +
image +
'<tr><td style="padding:34px 34px 0;">' +
'<p style="margin:0 0 8px;font:700 11px/1.4 ' + SANS + ';letter-spacing:.16em;text-transform:uppercase;color:#34d399;">The Founder\'s Break</p>' +
'<h1 style="margin:0;font:700 27px/1.18 ' + SANS + ';letter-spacing:-.02em;color:#f1f0ff;">' + opts.headline + '</h1>' +
'</td></tr>' +
'<tr><td style="padding:16px 34px 34px;font:400 16px/1.65 ' + SANS + ';color:#c2c0d4;">' + opts.body + '</td></tr>' +
'<tr><td style="padding:0 34px 30px;">' +
'<p style="margin:0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;border-top:1px solid rgba(255,255,255,.08);padding-top:18px;">' +
'Hosted by <a href="https://onetouch.studio" style="color:#34d399;text-decoration:none;">OneTouch Studio</a>. ' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>. ' +
'Reply and we will take you off the list.' +
'</p></td></tr>' +
'</table></td></tr></table></body></html>';
}
// The date and place, sized like an invitation rather than a spec sheet.
function marquee(when) {
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0 6px;background:rgba(16,185,129,.07);border:1px solid rgba(16,185,129,.22);border-radius:12px;">' +
'<tr><td style="padding:22px 24px;">' +
'<p style="margin:0;font:700 21px/1.3 ' + SANS + ';color:#f1f0ff;letter-spacing:-.01em;">' + esc(when) + '</p>' +
'<p style="margin:6px 0 0;font:600 16px/1.5 ' + SANS + ';color:#34d399;">7:00 to 9:00 PM</p>' +
'<p style="margin:14px 0 0;font:400 15px/1.55 ' + SANS + ';color:#c2c0d4;">' +
'<a href="' + MAP + '" style="color:#c2c0d4;text-decoration:none;border-bottom:1px solid rgba(16,185,129,.45);">' +
VENUE + '<br>' + STREET + ', ' + CITY + '</a></p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free to get in. 18 and over, so bring an ID.</p>' +
'</td></tr></table>';
}
function button(label, href) {
return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:26px 0 4px;"><tr><td style="border-radius:10px;background:#10b981;">' +
'<a href="' + href + '" style="display:inline-block;padding:13px 28px;font:700 15px/1 ' + SANS + ';color:#04140f;text-decoration:none;border-radius:10px;">' + label + '</a>' +
'</td></tr></table>';
}
// ── main ────────────────────────────────────────────────────────────────
var force = !!(event && event.force);
var now = etNow();
if (!force) {
if (now.day !== SEND_DAY) return { skipped: "not wednesday (et day " + now.day + ")" };
if (now.hour < 9 || now.hour > 20) return { skipped: "outside sending hours (et hour " + now.hour + ")" };
}
// Tomorrow is the Thursday we are reminding people about.
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 when = pretty(target);
var body =
'<p style="margin:0;">Tomorrow night. Same tables, same time. Come build something with people who are also building something.</p>' +
marquee(when) +
'<p style="margin:20px 0 0;">Still zero pool skills required. Bring a valid ID and whatever you are working on. If you know someone who should be in that room, forward this to them.</p>' +
button("See what to expect", SITE);
var campaign = await tool("create_campaign", {
name: "Founder's Break reminder " + target,
subject: "Tomorrow: The Founder's Break, " + when,
list_id: env.LIST_ID,
from_address_id: env.FROM_ADDRESS_ID,
reply_to: env.NOTIFY,
html_body: shell({
headline: "See you tomorrow.",
preview: when + ", 7:00 PM at Silver Q. Free, 18+.",
image: HERO,
imageAlt: "A blueprint drawing of a pool table and the break shot.",
body: body,
}),
text_body: "The Break is tomorrow, " + when + ".\n\n" + VENUE + ", " + STREET + ", " + CITY +
"\n7:00 to 9:00 PM. Free, 18+.\n\nZero pool skills required. Bring a valid ID.\n\n" + SITE,
tags: ["founders-break", "reminder"],
});
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);
return { ok: true, event_date: target, campaign_id: campaignId };

233
ops/founders-break-rsvp.js Normal file
View file

@ -0,0 +1,233 @@
// The Founder's Break - RSVP handler.
//
// Fires on form.submitted (form_name "rsvp"). Deterministic on purpose: an LLM
// in a transactional email path is a reliability problem, not a feature. Every
// string below is a fixed template.
//
// env: CORBEN_KEY (email + crm scoped), LIST_ID, FROM, NOTIFY
// User code is compiled as `async function (ctx) { ... }`, so the sandbox
// capabilities arrive on ctx rather than as globals.
var event = ctx.event, env = ctx.env, fetch = ctx.fetch, log = ctx.log;
var API = "https://api.corben.world/mcp";
var VENUE = "Silver Q Billiards & Sports Bar";
var STREET = "225 W University Ave";
var CITY = "Gainesville, FL 32601";
var SITE = "https://foundersbreak.onetouch.studio";
var MAP = "https://www.google.com/maps/search/?api=1&query=Silver+Q+Billiards+225+W+University+Ave+Gainesville+FL+32601";
async function tool(name, args) {
var res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + env.CORBEN_KEY },
body: JSON.stringify({ tool: name, arguments: args }),
});
if (!res.ok) throw new Error(name + " failed: " + res.status + " " + String(res.text).slice(0, 200));
return res.json;
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function prettyDate(iso) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso || "")) return "this Thursday";
var p = iso.split("-").map(Number);
var d = new Date(Date.UTC(p[0], p[1] - 1, p[2]));
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
}).format(d);
}
var HERO = "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/e7119448-83ba-4da4-95d8-b64045c21160/email-header-trim.png";
var SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// One shell for every outbound email so they all look like the site. `image`
// is optional: the internal notification does not need a photograph.
function shell(opts) {
var image = opts.image
? '<tr><td style="padding:0;"><img src="' + opts.image + '" width="560" alt="' + esc(opts.imageAlt || "") + '" ' +
'style="display:block;width:100%;max-width:560px;height:auto;border-radius:14px 14px 0 0;border:0;"></td></tr>'
: "";
return '<!DOCTYPE html><html><head><meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<meta name="color-scheme" content="dark light">' +
'<meta name="supported-color-schemes" content="dark light"></head>' +
'<body style="margin:0;padding:0;background:#06060b;">' +
'<div style="display:none;max-height:0;overflow:hidden;opacity:0;">' + esc(opts.preview || "") + '</div>' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#06060b;padding:32px 16px;">' +
'<tr><td align="center">' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#0c0c14;border:1px solid rgba(16,185,129,0.18);border-radius:14px;overflow:hidden;">' +
image +
'<tr><td style="padding:34px 34px 0;">' +
'<p style="margin:0 0 8px;font:700 11px/1.4 ' + SANS + ';letter-spacing:.16em;text-transform:uppercase;color:#34d399;">The Founder\'s Break</p>' +
'<h1 style="margin:0;font:700 27px/1.18 ' + SANS + ';letter-spacing:-.02em;color:#f1f0ff;">' + opts.headline + '</h1>' +
'</td></tr>' +
'<tr><td style="padding:16px 34px 34px;font:400 16px/1.65 ' + SANS + ';color:#c2c0d4;">' + opts.body + '</td></tr>' +
'<tr><td style="padding:0 34px 30px;">' +
'<p style="margin:0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;border-top:1px solid rgba(255,255,255,.08);padding-top:18px;">' +
'Hosted by <a href="https://onetouch.studio" style="color:#34d399;text-decoration:none;">OneTouch Studio</a>. ' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>. ' +
'Reply and we will take you off the list.' +
'</p></td></tr>' +
'</table></td></tr></table></body></html>';
}
// The date and place, sized like an invitation rather than a spec sheet.
function marquee(when) {
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0 6px;background:rgba(16,185,129,.07);border:1px solid rgba(16,185,129,.22);border-radius:12px;">' +
'<tr><td style="padding:22px 24px;">' +
'<p style="margin:0;font:700 21px/1.3 ' + SANS + ';color:#f1f0ff;letter-spacing:-.01em;">' + esc(when) + '</p>' +
'<p style="margin:6px 0 0;font:600 16px/1.5 ' + SANS + ';color:#34d399;">7:00 to 9:00 PM</p>' +
'<p style="margin:14px 0 0;font:400 15px/1.55 ' + SANS + ';color:#c2c0d4;">' +
'<a href="' + MAP + '" style="color:#c2c0d4;text-decoration:none;border-bottom:1px solid rgba(16,185,129,.45);">' +
VENUE + '<br>' + STREET + ', ' + CITY + '</a></p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free to get in. 18 and over, so bring an ID.</p>' +
'</td></tr></table>';
}
function button(label, href) {
return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:26px 0 4px;"><tr><td style="border-radius:10px;background:#10b981;">' +
'<a href="' + href + '" style="display:inline-block;padding:13px 28px;font:700 15px/1 ' + SANS + ';color:#04140f;text-decoration:none;border-radius:10px;">' + label + '</a>' +
'</td></tr></table>';
}
// ── main ────────────────────────────────────────────────────────────────
var meta = event || {};
if (meta.form_name && meta.form_name !== "rsvp") return { skipped: "not the rsvp form" };
var data = meta.data || {};
var email = String(data.email || "").trim();
var name = String(data.name || "").trim();
var building = String(data.building || "").trim();
var eventDate = String(data.event_date || "").trim();
// 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" };
var first = name.split(/\s+/)[0] || "there";
var last = name.split(/\s+/).slice(1).join(" ");
var when = prettyDate(eventDate);
// 1. Find or create the contact.
//
// list_contacts does a fuzzy search, so match on exact email ourselves. Sending
// mail auto-creates an "auto-created" contact for the recipient, so the same
// address can legitimately appear several times: prefer the row we tagged,
// then any real row, and only fall back to an auto-created one.
var contactId = null;
var found = await tool("list_contacts", { search: email });
var rows = Array.isArray(found) ? found : (found && found.result) || [];
var exact = rows.filter(function (r) {
return String(r.email || "").toLowerCase() === email.toLowerCase();
});
var tagged = exact.filter(function (r) { return (r.tags || []).indexOf("founders-break") !== -1; });
var real = exact.filter(function (r) { return (r.tags || []).indexOf("auto-created") === -1; });
var pick = tagged[0] || real[0] || exact[0];
if (pick) contactId = pick.id;
log("contact search: " + rows.length + " rows, " + exact.length + " exact, picked " +
(pick ? pick.id + " <" + (pick.email || "no email") + ">" : "none"));
if (!contactId) {
var made = await tool("create_contact", {
first_name: first,
last_name: last,
email: email,
tags: ["founders-break"],
notes: (eventDate || "RSVP") + (building ? " " + building : ""),
});
contactId = made && (made.id || (made.result && made.result.id));
log("created contact " + contactId + " for " + email);
}
// A list member with no address is dead weight and never receives anything.
if (!contactId) return { skipped: "could not resolve a contact for " + email };
// create_contact dedupes server-side and can hand back an existing record that
// has no email on it, which would put an unmailable member on the list. Write
// the address back explicitly so the row is always usable.
if ((!pick || !pick.email) && contactId) {
try {
await tool("update_contact", {
id: contactId,
email: email,
first_name: first,
last_name: last,
tags: ["founders-break"],
});
} catch (e) {
log("update_contact: " + e.message);
}
}
// 2. Put them on the list. Already-a-member is not an error worth failing over.
var listed = false;
if (contactId) {
try {
await tool("add_list_members", { list_id: env.LIST_ID, contact_ids: [contactId] });
listed = true;
} catch (e) {
log("add_list_members: " + e.message);
}
}
// 3. Confirmation to the person who RSVPed.
var confirmBody =
'<p style="margin:0;">Nice one, ' + esc(first) + '. Nothing else to do now. Just turn up and find us at the tables.</p>' +
marquee(when) +
'<p style="margin:20px 0 0;">You do not need to be good at pool. Most of us are not. Bring whatever you are working on and we will point you at the people worth meeting.</p>' +
'<p style="margin:14px 0 0;">We will nudge you the day before so it does not sneak up on you.</p>' +
button("See what to expect", SITE);
await tool("send_email", {
from: env.FROM,
to: email,
subject: "You are in: The Founder's Break, " + when,
html: shell({
headline: "You are in.",
preview: when + ", 7:00 PM at Silver Q. Free, 18+.",
image: HERO,
imageAlt: "A blueprint drawing of a pool table and the break shot.",
body: confirmBody,
}),
text: "Nice one, " + first + ". Nothing else to do now, just turn up and find us at the tables.\n\n" + when + ", 7:00 to 9:00 PM\n" +
VENUE + "\n" + STREET + ", " + CITY + "\nFree to get in. 18 and over, so bring an ID.\n\n" +
"You do not need to be good at pool. Bring whatever you are working on.\n" +
"We will nudge you the day before.\n\n" + SITE,
});
// 4. Internal heads-up. Plain on purpose, this one is for us.
var line = function (label, value) {
return '<p style="margin:0 0 10px;font:400 15px/1.5 ' + SANS + ';color:#c2c0d4;">' +
'<span style="color:#8b89a6;">' + label + '</span> ' +
'<span style="color:#f1f0ff;font-weight:600;">' + esc(value) + '</span></p>';
};
var notifyBody =
line("Email", email) +
line("Building", building || "did not say") +
line("Coming to", when) +
line("Mailing list", listed ? "added" : "not added, check the logs") +
'<p style="margin:18px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Their confirmation has already gone out.</p>';
await tool("send_email", {
from: env.FROM,
to: env.NOTIFY,
subject: (name || email) + " is coming on " + when,
html: shell({
headline: esc(name || "Someone") + " RSVPed.",
preview: (name || email) + " for " + when,
body: notifyBody,
}),
text: (name || "(no name)") + " RSVPed for " + when + "\nEmail: " + email +
"\nBuilding: " + (building || "did not say") + "\nList: " + (listed ? "added" : "not added"),
});
return { ok: true, contact_id: contactId, listed: listed, event_date: eventDate };