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(when) + ' ' + + '7:00 to 9:00 PM ' + + '' +
+ '' +
+ VENUE + ' Free to get in. 18 and over, so bring an ID. ' + + ' |
| ' + + '' + label + '' + + ' |
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(when) + ' ' + + '7:00 to 9:00 PM ' + + '' +
+ '' +
+ VENUE + ' Free to get in. 18 and over, so bring an ID. ' + + ' |
| ' + + '' + label + '' + + ' |
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 };