Check in the RSVP and reminder function sources, document the whole flow
This commit is contained in:
parent
8ec3f51792
commit
149a6eaa71
4 changed files with 481 additions and 19 deletions
68
README.md
68
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.
|
||||
|
|
|
|||
26
ops/README.md
Normal file
26
ops/README.md
Normal 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
|
||||
```
|
||||
173
ops/founders-break-reminder.js
Normal file
173
ops/founders-break-reminder.js
Normal 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, "&").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
|
||||
? '<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
233
ops/founders-break-rsvp.js
Normal 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, "&").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
|
||||
? '<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 };
|
||||
Loading…
Reference in a new issue