From 149a6eaa7121c20c3f40741d37d1a931889d1056 Mon Sep 17 00:00:00 2001 From: Devin Willis Date: Wed, 26 Aug 2026 14:52:51 -0400 Subject: [PATCH] Check in the RSVP and reminder function sources, document the whole flow --- README.md | 68 +++++++--- ops/README.md | 26 ++++ ops/founders-break-reminder.js | 173 ++++++++++++++++++++++++ ops/founders-break-rsvp.js | 233 +++++++++++++++++++++++++++++++++ 4 files changed, 481 insertions(+), 19 deletions(-) create mode 100644 ops/README.md create mode 100644 ops/founders-break-reminder.js create mode 100644 ops/founders-break-rsvp.js diff --git a/README.md b/README.md index 6a104de..cae6c38 100644 --- a/README.md +++ b/README.md @@ -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 views/index.ejs the single page, including the next-date logic public/css/style.css brand tokens lifted from onetouch.studio -public/js/main.js nav state, scroll reveal, RSVP submit -public/assets/ the recurring .ics -public/images/ logo and favicon +public/js/main.js nav state, scroll reveal, live countdown, RSVP submit +public/assets/ the recurring .ics and the print-resolution QR +public/images/ logo, favicon, RSVP QR ``` ## The date is never hard-coded -`views/index.ejs` computes the next occurrence at render time from -`weekday` + `firstEvent` in `site.config.js`, so the page is correct on every -load without anyone touching it. After 9:00 PM on an event night it rolls to -the following week. +`views/index.ejs` computes the next occurrence at render time from `weekday` + +`firstEvent` in `site.config.js`. `public/js/main.js` runs the same rule in the +browser, so the countdown ticks and rolls itself over to the following Thursday +once the night ends, with no reload and no redeploy. ## Cancelling a week @@ -29,24 +29,54 @@ Add the ISO date to `cancelled` in `site.config.js` and redeploy: cancelled: ["2026-11-26"], // Thanksgiving ``` -The page then skips that Thursday, shows a notice naming the cancelled date, -and points at the next one that is actually happening. The `.ics` in -`public/assets/` is a plain weekly recurrence and does not know about -cancellations, so send the newsletter a heads-up too. +The page then skips that Thursday, shows a notice naming the cancelled date, and +points at the next one that is actually happening. Two other places do **not** +read this list and need the same date added by hand: + +- `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 ``` -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. -## RSVPs +## What happens when somebody RSVPs -The form POSTs to `/forms/rsvp`, which the Corben sites renderer stores in -`site_form_submissions` and announces as a `form.submitted` event. Read them -with `get_site_form_submissions`. JavaScript submits it in place; without -JavaScript the plain POST falls back to `/?rsvp=ok` and the page shows the -same confirmation. +The form POSTs to `/forms/rsvp`. The Corben sites renderer stores it in +`site_form_submissions` and fires a `form.submitted` event. From there it is all +plain JavaScript, deliberately: there is no LLM anywhere in the send path. + +| 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. diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..19db4cc --- /dev/null +++ b/ops/README.md @@ -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 +``` diff --git a/ops/founders-break-reminder.js b/ops/founders-break-reminder.js new file mode 100644 index 0000000..9425af3 --- /dev/null +++ b/ops/founders-break-reminder.js @@ -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, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +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 + ? '' + esc(opts.imageAlt || ' + : ""; + + return '' + + '' + + '' + + '' + + '' + + '
' + esc(opts.preview || "") + '
' + + '' + + '
' + + '' + + image + + '' + + '' + + '' + + '
' + + '

The Founder\'s Break

' + + '

' + opts.headline + '

' + + '
' + opts.body + '
' + + '

' + + 'Hosted by OneTouch Studio. ' + + 'You are getting this because you RSVPed at foundersbreak.onetouch.studio. ' + + 'Reply and we will take you off the list.' + + '

'; +} + +// The date and place, sized like an invitation rather than a spec sheet. +function marquee(when) { + return '' + + '
' + + '

' + esc(when) + '

' + + '

7:00 to 9:00 PM

' + + '

' + + '' + + VENUE + '
' + STREET + ', ' + CITY + '

' + + '

Free to get in. 18 and over, so bring an ID.

' + + '
'; +} + +function button(label, href) { + return '
' + + '' + label + '' + + '
'; +} + +// ── 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 = + '

Tomorrow night. Same tables, same time. Come build something with people who are also building something.

' + + marquee(when) + + '

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.

' + + 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 }; diff --git a/ops/founders-break-rsvp.js b/ops/founders-break-rsvp.js new file mode 100644 index 0000000..a68e422 --- /dev/null +++ b/ops/founders-break-rsvp.js @@ -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, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +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 + ? '' + esc(opts.imageAlt || ' + : ""; + + return '' + + '' + + '' + + '' + + '' + + '
' + esc(opts.preview || "") + '
' + + '' + + '
' + + '' + + image + + '' + + '' + + '' + + '
' + + '

The Founder\'s Break

' + + '

' + opts.headline + '

' + + '
' + opts.body + '
' + + '

' + + 'Hosted by OneTouch Studio. ' + + 'You are getting this because you RSVPed at foundersbreak.onetouch.studio. ' + + 'Reply and we will take you off the list.' + + '

'; +} + +// The date and place, sized like an invitation rather than a spec sheet. +function marquee(when) { + return '' + + '
' + + '

' + esc(when) + '

' + + '

7:00 to 9:00 PM

' + + '

' + + '' + + VENUE + '
' + STREET + ', ' + CITY + '

' + + '

Free to get in. 18 and over, so bring an ID.

' + + '
'; +} + +function button(label, href) { + return '
' + + '' + label + '' + + '
'; +} + +// ── 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 = + '

Nice one, ' + esc(first) + '. Nothing else to do now. Just turn up and find us at the tables.

' + + marquee(when) + + '

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.

' + + '

We will nudge you the day before so it does not sneak up on you.

' + + 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 '

' + + '' + label + ' ' + + '' + esc(value) + '

'; +}; + +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") + + '

Their confirmation has already gone out.

'; + +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 };