Skip to content

Scheduling: reminders and timed webhooks - #329

Merged
pumfleet merged 31 commits into
mainfrom
worktree-reminders
Nov 4, 2026
Merged

pumfleet merged 31 commits into
mainfrom
worktree-reminders

Conversation

@pumfleet

@pumfleet pumfleet commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Replaces Scheduling's one fixed 24-hour reminder with a single reminders list, set per environment or per event type, and adds webhooks for when a booking starts and ends. It covers what Cal.com's Workflows and MEETING_STARTED/MEETING_ENDED do for these cases, with no workflow objects to manage.

await coss.scheduling.settings.update({
  email_from: "appointments@acme.com",
  reminders: [
    { anchor: "start", before_minutes: 1440, send: ["email_attendee"] },
    { anchor: "start", before_minutes: 60, send: ["sms_attendee", "email_host"] },
    { key: "follow_up", anchor: "end", after_minutes: 120, send: ["email_attendee", "webhook"],
      subject: "How did it go, {{attendee_first_name}}?",
      button: { label: "Feedback", url: "https://acme.com/f?b={{booking_id}}" },
      only_if: { metadata: { plan: "pro" } } },
  ],
});
  • Timing: from a booking's start or end, or after it's confirmed, rescheduled, cancelled, declined, requested or marked a no-show.
  • Recipients: attendees and hosts by email or SMS (hosts gain a phone), up to five fixed addresses, or a webhook.
  • New webhooks: scheduling.booking.started, .ended and .reminder.
  • Read-back: GET /v1/scheduling/bookings/{id}/notifications lists every queued send and what became of it, never who it went to. It also shows on the booking's dashboard page.
  • Dashboard: a Reminders page with a live email preview and SMS segment count, a no-show button, and phone on hosts.
  • Also: the API, SDK, MCP tools, CLI commands and docs. notifications on event types now works (the docs already promised it), and settings gain email_reply_to.

Behaviour to know about

  • Customer words never go out under coss.com's address. Custom subject, message and button need email_from. If that domain stops being verified, coss.com sends its standard wording.
  • Cost cap: without a working email_from, at most two reminders send email from coss.com's address, since those are metered on our account. MAX_REMINDER_EMAILS_FROM_COSS.
  • Languages: standard wording, labels, dates and {{time_until}} follow each recipient's language, in all 16 catalogs. A fixed address gets the first host's. A customer's own words go out as written.
  • Guests: email_attendee reaches them from the customer's own address only, with no manage link, and not for requests or no-shows.
  • A behaviour change: notifications.reminder.email_host was accepted and ignored. It now really emails hosts.
  • Answers stay out of variables, per packages/scheduling/AGENTS.md. This is the one deliberate gap against Cal.com's templates.

Deploying

  • Scheduling's worker claims only rows it understands #315 is live first. That guard made today's workers claim only rows they understand, because deploys roll every service together and an old worker would send these new rows as SMS. This PR removes the guard.
  • Migrations: 0137 is additive. 0138 rewrites queued reminders to the new row shape, and seeds one sync per environment with confirmed bookings still to come, so those bookings get their started/ended rows. The worker does that walk in pages through its normal code, and it only adds what's still ahead, so nothing is sent because of it.
  • carryAnchorlessReminders only exists for this deploy's overlap and can go afterwards.

Testing

  • bun run verify; the docs build, which type-checks every SDK sample.
  • packages/scheduling/test/reminders.test.ts: timing, edits at every stage, group seats, guests, races on real Postgres, and 0138's own SQL run against bookings in the old shape.
  • apps/web/test/scheduling-reminder-draft.test.ts: every anchor, direction and recipient the dialog can produce, against the real schema.
  • Real servers, real timers: a booking delivered its reminder, started and ended webhooks on time with valid signatures, a filtered reminder was skipped, and a cancelled booking's rows were dropped.
  • Browser (Playwright, not committed): adding, editing and removing reminders, refused text, the preview and the host's phone.
  • Not tested: real SES or Twilio (fake providers throughout), and the verified-domain check against a live domain (stubbed in tests).

Review

24 Codex rounds. Round 24 was clean on the final head. Rounds 11, 15, 18, 19 and 23 were clean too, but CI or a moved main touched the same code, so it was reviewed again.

Round Found and fixed
1 P1: a long list pushed later rules past the classifier's limit, so they went out unscreened. P2: generated keys the schema refused (the dashboard couldn't add an "after" reminder); before_minutes: 0 never sent; only_if missed Meet and Teams once their link existed; clearing a policy didn't reach existing bookings; existing bookings had no started/ended
2 P1: old workers misreading new rows during a rolling deploy (#315). P2: an unverified email_from got round the cap; retry keys for fixed addresses depended on list order; mid-deploy rows lost their reminder on a location change
3 A booker's attacker.dev passed the name filter; dedupe keys lacked the anchor; read-back stopped at 500 and claimed there was no more
4 Clearing email_from wasn't checked against event types' lists; hidden dialog fields made a save fail
5 Editing a waiting reminder's delay lost it; event types past the first 100 could fall back to the environment's list
6 A worker could send a row another had moved later
7 Retiming a follow-up after its meeting ended lost it
8 {{booking_id}} in a button's hostname meant what was verified wasn't what got sent
9 The dialog changed metadata conditions on save; an MCP tool not marked as sending could schedule sends
10 Changing who a waiting reminder goes to dropped it
12 Same family: rows assumed the sync had already run. Removed the assumption
13 A retried SMS with changed text was refused, blocking later recipients
14 A retimed rule looked like a new one, losing or resending reminders. Reconcile now identifies rows by rule, recipient and moment
15 (CI) CodeQL: the {{variable}} pattern could backtrack badly on long input
16 Recipients replaced after a reminder was due, and a recipient added to a send_if_late reminder, were lost or finished too_late
17 The dialog regenerated a derived key on a timing edit, dropping what was waiting
20 attacker。dev and invisible characters got past the name filter
21 Reminder text could hold a link with an uncommon ending, or one finished by a variable. Standard wording followed the anchor, not when it was sent, so it could be untrue for a short meeting
22 A reminder made later was judged late from its old time if no worker ran in between

No findings were rejected. The P2 on existing bookings and the P1 on rolling deploys were decided with the author, because each changed how this reaches production.

Two flaky tests turned up in code this doesn't touch: webhook listeners > acknowledgements… (since changed on main) and, under load, @coss/push > a worker that lost its claim….

One reminders list, per environment or event type, says what goes out
around a booking: timed from its start, end or any change to it, to
attendees, hosts, fixed addresses or a webhook, optionally in the
customer's own words, with one button on a verified domain and a filter
on the booking's metadata and location.

New webhooks: scheduling.booking.started, .ended and .reminder. Each
booking lists what was queued and what became of it, including why
something wasn't sent. Hosts take a phone, event types take notifications
(the docs already promised it), and settings take email_reply_to.

Staleness moves from the booking's version to the moment a row is timed
from, so a location change or a no-show no longer kills a reminder. A
customer's words never go out under coss.com's address, and without
email_from at most two reminders send email from it.
Main added guests on bookings: email_attendee reaches them on the same
terms as other mail (the customer's own address only, no manage link, not
for what isn't about them). This branch's migrations are regenerated on
top of main's 0126.
- Screen each reminder on its own: the classifier reads 8,000 characters
  and a list can hold over 25,000, so later rules went out unscreened.
- Generated keys for after-anchor reminders contained a + the input schema
  refuses, so the dashboard couldn't add one. They're now end-after-60.
- before_minutes: 0 could never send: it was too late the moment it was
  due. Zero is now the moment itself, in words that fit.
- only_if location_types matches what was chosen, not the link it became,
  for Meet and Teams bookings and for group sessions.
- Clearing a notifications policy can bring the default reminder back, so
  it queues a sync as setting one does.
The backfill migration seeds one reminder sync per environment with
confirmed bookings still to come, so the worker's usual walk adds the rows
they lack, a page at a time. It only adds what's ahead, so nothing is sent
because of it.

The migration also rewrites old reminders' dedupe keys to the ones the
default rule makes. Otherwise the walk would take them for something no
longer wanted, and drop any due at that moment. Tested by running the
migration's own statements against bookings in the old shape.
- The cap on what coss.com's address sends is enforced where the fallback
  is chosen: an unverified email_from could get round it. The notifier now
  says when it didn't send, so the row finishes no_sender, not sent.
- Fixed addresses' retry keys come from the address, not its place in the
  list: a list reordered before a retry would have swapped who was done.
- Reminders queued by older tasks mid-deploy are carried across a location
  change, as they always were.
Takes main's host locations, editable hosts and event types, reschedule
action, agent surface checks and field descriptions alongside reminders.
Hosts' phone joins the new edit form, the new tools and commands are
listed where the agent surface tests want them, and the response fields
this adds are described like the rest. Migrations regenerated on top of
main's: 0128 and 0129.

Removes the claim filter from #315: it was there so that workers older
than these rows wouldn't touch them, and every worker from here on knows
them. Merge this only once #315 has finished deploying.
- safeName knows no list of endings: a booker's attacker.dev went into
  unscreened email because the shared link pattern only knows a few.
- A reminder's dedupe key names its anchor: a rule edited onto another
  moment could collide with its old row and be dropped as stale. The
  backfill migration makes the same key.
- A booking's notifications come a page at a time. They stopped at 500
  with has_more false, hiding what was still waiting.
- Taking email_from away is refused while an event type's own reminders
  need it, naming the event type, not only the environment's list.
- The reminder dialog saves only what applies to its timing and recipients.
  A hidden invitation, text message or email wording made the save fail
  with nothing on screen to fix it. The draft moves to its own module, and
  every anchor, direction and recipient is tested against the real schema.
- A waiting reminder timed from a change to a booking takes its rule's new
  timing. Its old row was refused for the changed offset and nothing made a
  new one, so it was lost. Made later, it waits; made sooner, it goes when
  it comes due.
- The reminders page finds an event type past its first 100 on its own, so
  a save can't fall back to every event type's list, and an event type's
  page links to its reminders.
A worker holding a row re-checks its due time as it reads it now: another
worker may have moved it to a rule's later moment, and it would have gone
out early.
A follow-up waiting on a meeting that's over keeps going when its delay
changes. The walk that makes rows for a changed list skips ended bookings,
so nothing replaced it. Only rows on bookings the walk visits defer to it.
A button's host has to be the same for every booking: with {{booking_id}}
in the hostname, what was verified wasn't what got emailed.
- The reminder dialog keeps a rule's metadata conditions exactly as they
  were unless they're edited: its key=value lines can't hold every value,
  and a changed value stops matching bookings.
- save_scheduling_event_type refuses reminders and notifications. It isn't
  annotated as reaching people and they can, so they go through the tool
  that says so.
A waiting reminder timed from a change to a booking goes to whoever its
rule names by the time it's due. A row can only speak for its own
recipient, and the one taken off would end the reminder for everyone, so a
row coming due adds what the rule now asks for, where the walk doesn't
reach. A change of timing alone still sends it once.
Takes calendar delegation alongside reminders. Hosts keep both phone and
calendar_account, and updateHost's new transaction writes phone too. This
branch's migrations are regenerated on top of main's: 0129 and 0130.
Takes main's slot diagnostics alongside reminders.

A row coming due no longer assumes a changed list's walk has run. It may
not have, and it only reaches some bookings, so an edit just before a
reminder was due could lose it. Every row now takes its rule's current
timing and recipients itself; the dedupe keys keep the walk safe beside it.
Takes main's fairer pools and email size metering alongside reminders.
This branch's migrations are regenerated on top of main's: 0130 and 0131.

A text accepted on an earlier attempt isn't refused for reading differently
on a retry. {{time_until}} or an edit changes its body under the same key,
and the refusal stopped every later recipient being reached.
A rule given a new offset is the same rule. Reconciling took a row's
identity from its dedupe key, which holds the offset, so a retimed rule
looked new: a waiting reminder could be dropped with nothing to replace it,
and one already delivered could go again. It now goes by rule, recipient and
moment: what's waiting is retimed in place, and what went out counts as done.
CodeQL flagged it as polynomial on long runs of whitespace after {{. It
matches the braces now and trims the name afterwards.
Whoever a reminder is for by the time it goes out gets it, when it goes out.
A recipient swapped in after its moment, with the walk ahead of the worker,
was left with nothing because a list change adds only moments still ahead:
that now applies only to rules that weren't already owed. And a recipient
added to a send_if_late reminder takes the time it's owed from, rather than
its long-past moment.
The reminder dialog keeps a rule's key when its timing is edited, even one
made from the old timing. It's what waiting rows are found by, so a new key
made it a different reminder, and they were dropped. Without a key, an API
list's new timing is a new reminder, and the docs say so.
Takes main's attendee booking limits alongside reminders. This branch's
migrations are regenerated on top of main's: 0131 and 0132.
Takes main's booking history and event type descriptions alongside
reminders. This branch's migrations are regenerated on top of main's: 0134
and 0135.
Main brought localized booking messages, so reminders follow them:

- The standard words of reminders are in every catalog (happening,
  finished, no-show, the fixed-address reason, stand-ins for a name), and
  reminder-copy.ts reads them through standardSubject and standardLead.
- Labels, subjects, leads, reasons, the manage button, SMS brand and opt-out
  line, dates and time_until follow the recipient: an attendee's or guest's
  locale, a host's own, and for a fixed address the first host's.
- A customer's own words stay as written.
- Migrations renumbered on top of main's 0134: 0135 and 0136.

Round 20: safeName checks a normalized copy of a name, as linkedHosts does,
so attacker。dev and hosts split by invisible characters render as nothing.
Ordinary names, marks included, come back as typed.
- A customer's words in a reminder refuse anything shaped like a domain, read
  as a browser would and with variables filled in, so attacker.dev and
  attacker.{{brand}} can't get past a list of endings. The button stays the one
  link, and the one that's checked.
- The standard words follow when a reminder goes out against the booking's own
  times: an hour before the end of a half-hour meeting is still before it, and
  ninety minutes after its start is after it.
# Conflicts:
#	packages/db/migrations/meta/0135_snapshot.json
#	packages/db/migrations/meta/_journal.json
A reminder made later and already past that moment, with no worker to move
it in between, is late from its new moment rather than the one it was queued
for. Made sooner, or queued late by send_if_late, it stays late from its row's
own due time.
# Conflicts:
#	AGENTS.md
#	apps/api/src/routes/scheduling.ts
#	apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/actions.ts
#	apps/worker/src/jobs.ts
#	packages/db/migrations/meta/0136_snapshot.json
#	packages/db/migrations/meta/_journal.json
#	packages/scheduling/src/bookings.ts
#	packages/scheduling/src/event-types.ts
#	packages/scheduling/src/hosts.ts
@pumfleet
pumfleet added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 820256d Nov 4, 2026
31 checks passed
@pumfleet
pumfleet deleted the worktree-reminders branch November 4, 2026 23:15
@linear-code

linear-code Bot commented Nov 4, 2026

Copy link
Copy Markdown

ENG-8079

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants