Repository navigation
Scheduling: reminders and timed webhooks - #329
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces Scheduling's one fixed 24-hour reminder with a single
reminderslist, set per environment or per event type, and adds webhooks for when a booking starts and ends. It covers what Cal.com's Workflows andMEETING_STARTED/MEETING_ENDEDdo for these cases, with no workflow objects to manage.phone), up to five fixed addresses, or a webhook.scheduling.booking.started,.endedand.reminder.GET /v1/scheduling/bookings/{id}/notificationslists every queued send and what became of it, never who it went to. It also shows on the booking's dashboard page.phoneon hosts.notificationson event types now works (the docs already promised it), and settings gainemail_reply_to.Behaviour to know about
subject,messageandbuttonneedemail_from. If that domain stops being verified, coss.com sends its standard wording.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.{{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.email_attendeereaches them from the customer's own address only, with no manage link, and not for requests or no-shows.notifications.reminder.email_hostwas accepted and ignored. It now really emails hosts.packages/scheduling/AGENTS.md. This is the one deliberate gap against Cal.com's templates.Deploying
0137is additive.0138rewrites queued reminders to the new row shape, and seeds one sync per environment with confirmed bookings still to come, so those bookings get theirstarted/endedrows. 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.carryAnchorlessRemindersonly 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, and0138'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.reminder,startedandendedwebhooks on time with valid signatures, a filtered reminder was skipped, and a cancelled booking's rows were dropped.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
maintouched the same code, so it was reviewed again.before_minutes: 0never sent;only_ifmissed Meet and Teams once their link existed; clearing a policy didn't reach existing bookings; existing bookings had nostarted/endedemail_fromgot round the cap; retry keys for fixed addresses depended on list order; mid-deploy rows lost their reminder on a location changeattacker.devpassed the name filter; dedupe keys lacked the anchor; read-back stopped at 500 and claimed there was no moreemail_fromwasn't checked against event types' lists; hidden dialog fields made a save fail{{booking_id}}in a button's hostname meant what was verified wasn't what got sent{{variable}}pattern could backtrack badly on long inputsend_if_latereminder, were lost or finishedtoo_lateattacker。devand invisible characters got past the name filterNo 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….