From 6d31dd3b26e5adde663d86b4be8f6c093d777d26 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Wed, 4 Nov 2026 12:02:16 -0600 Subject: [PATCH 01/18] Scheduling: reminders and timed webhooks 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. --- .changeset/scheduling-reminders.md | 7 + AGENTS.md | 4 +- apps/api/openapi.json | 1406 +- apps/api/src/app.ts | 21 +- apps/api/src/routes/scheduling.ts | 35 +- apps/api/test/app.test.ts | 1 + apps/api/test/contract-types.ts | 5 + apps/api/test/mcp-contract.test.ts | 6 + apps/api/test/sdk-contract.test.ts | 81 + .../site/content/docs/(platform)/webhooks.mdx | 3 + apps/site/content/docs/(tools)/cli.mdx | 5 + apps/site/content/docs/(tools)/mcp.mdx | 6 +- .../site/content/docs/scheduling/bookings.mdx | 13 +- apps/site/content/docs/scheduling/index.mdx | 2 +- apps/site/content/docs/scheduling/meta.json | 1 + .../content/docs/scheduling/notifications.mdx | 18 +- .../content/docs/scheduling/reminders.mdx | 299 + apps/site/content/products/scheduling.mdx | 2 +- apps/site/src/lib/changelog.ts | 6 + apps/site/src/lib/compare-data/trust.ts | 4 +- apps/site/src/lib/sdk-samples.ts | 4 + apps/web/package.json | 1 + .../[envSlug]/scheduling/[bookingId]/page.tsx | 85 +- .../[envSlug]/scheduling/actions.ts | 64 +- .../[envSlug]/scheduling/reminders/page.tsx | 39 + .../scheduling/reminders/reminders-editor.tsx | 927 + .../scheduling/scheduling-actions.tsx | 48 + apps/web/src/lib/scheduling.ts | 17 + apps/worker/src/jobs.ts | 8 + apps/worker/src/scheduling-notifier.ts | 28 +- apps/worker/test/scheduling-notifier.test.ts | 68 + bun.lock | 1 + packages/cli/README.md | 1 + packages/cli/src/commands/scheduling.ts | 117 + packages/cli/test/scheduling.test.ts | 56 + packages/dashboard-routes/src/index.ts | 1 + .../db/migrations/0126_lovely_supernaut.sql | 25 + .../0127_scheduling_reminder_backfill.sql | 14 + .../db/migrations/meta/0126_snapshot.json | 31236 ++++++++++++++++ .../db/migrations/meta/0127_snapshot.json | 31236 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 14 + packages/db/src/id.ts | 1 + packages/db/src/schema/scheduling.ts | 75 + packages/mcp/src/tools/scheduling.ts | 96 +- packages/mcp/test/server.test.ts | 4 + packages/scheduling/AGENTS.md | 27 +- packages/scheduling/package.json | 1 + packages/scheduling/src/booking-record.ts | 160 + packages/scheduling/src/bookings.ts | 148 +- packages/scheduling/src/calendar/writes.ts | 2 - packages/scheduling/src/errors.ts | 2 + packages/scheduling/src/event-types.ts | 52 +- packages/scheduling/src/hosts.ts | 3 + packages/scheduling/src/index.ts | 1 + .../scheduling/src/notification-policy.ts | 107 + packages/scheduling/src/notifications.ts | 732 +- packages/scheduling/src/reminder-copy.ts | 171 + packages/scheduling/src/reminders.ts | 602 + packages/scheduling/src/schemas.ts | 369 +- packages/scheduling/src/screening.ts | 4 +- packages/scheduling/src/settings.ts | 91 +- packages/scheduling/src/types.ts | 4 + packages/scheduling/test/hipaa-mode.test.ts | 23 + packages/scheduling/test/reminders.test.ts | 1220 + packages/sdk/src/core/routes.generated.ts | 1 + packages/sdk/src/resources/scheduling.ts | 15 +- packages/sdk/src/types/scheduling.ts | 124 +- packages/sdk/src/webhook-event-types.ts | 3 + tooling/sdkgen/src/methods.ts | 1 + 69 files changed, 69217 insertions(+), 737 deletions(-) create mode 100644 .changeset/scheduling-reminders.md create mode 100644 apps/site/content/docs/scheduling/reminders.mdx create mode 100644 apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/reminders/page.tsx create mode 100644 apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/reminders/reminders-editor.tsx create mode 100644 packages/db/migrations/0126_lovely_supernaut.sql create mode 100644 packages/db/migrations/0127_scheduling_reminder_backfill.sql create mode 100644 packages/db/migrations/meta/0126_snapshot.json create mode 100644 packages/db/migrations/meta/0127_snapshot.json create mode 100644 packages/scheduling/src/booking-record.ts create mode 100644 packages/scheduling/src/notification-policy.ts create mode 100644 packages/scheduling/src/reminder-copy.ts create mode 100644 packages/scheduling/src/reminders.ts create mode 100644 packages/scheduling/test/reminders.test.ts diff --git a/.changeset/scheduling-reminders.md b/.changeset/scheduling-reminders.md new file mode 100644 index 000000000..5bd08dd64 --- /dev/null +++ b/.changeset/scheduling-reminders.md @@ -0,0 +1,7 @@ +--- +"coss": minor +"coss-mcp": minor +"coss-cli": minor +--- + +Scheduling reminders: `reminders` on `coss.scheduling.settings` and event types say what goes out around a booking, timed from its start, end or any change to it, to attendees, hosts, fixed addresses or a webhook, in your own words if you like. `coss.scheduling.bookings.notifications()` lists what was queued and why anything wasn't sent. New webhooks: `scheduling.booking.started`, `scheduling.booking.ended` and `scheduling.booking.reminder`. Hosts take a `phone`, event types take `notifications`, and settings take `email_reply_to`. The MCP server gets `get_scheduling_reminders`, `save_scheduling_reminders`, `list_scheduling_booking_notifications` and `mark_scheduling_no_show`, and the CLI `coss scheduling reminders`, `notifications` and `no-show`. diff --git a/AGENTS.md b/AGENTS.md index b340da70d..68af3f584 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -527,7 +527,7 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu across orgs like `authorizeLiveSender`), `receiveEmail`/`routeRecipients` (receiving; from the envelope recipients), `recordSmsEvent`/`ingestSmsEvents` (from the `sms_id` of the callback URL), `receiveSms` (from the receiving number), `handleStatusCallback`/`handleInboundCallback` (from the callback's subaccount), `authorizeLiveSmsSender`, `ingestDmarcReports` (from the report address's token), `reencryptWebhookSecrets`, `reencryptSchedulingData` (operator scripts), `authenticateHookPortalSession` (from the session), `ingestSupportEmails` (one environment, across senders) and the rest of `@coss/support`'s worker functions, `submitForm`/`getHostedForm` (from the form id of `/f/:id`), `confirmFormSubmission`/`findConfirmation` (from the verification token), and the worker scans `runDueWebhookDeliveries`, `runDueLogDrainDeliveries`, `runDueHookDeliveries`, `parkHeldHookDeliveries`, `runDueHookRecoveries`, the Hooks purges, `recheckDueDomains`, `recheckDueTrackingDomains`, `recheckDueReceivingDomains`, `reconcileDedicatedIpPools`, `sweepStaleClaims`, `sweepUnclaimedEmails`, `runDueReputationEvaluations`, `runDueSubaccountEvaluations`, `runDueOrganizationDeletions`, `runDueOrganizationLogos`, `runDueSubaccountDeletions`, `runDueDmarcNotifications`, `runDueMigrations`, `EmailService.dispatchDue`, `unclaimedEmailBacklog`, `unclaimedSmsBacklog` (the `dispatch-backlog` loop: counts only), `SmsService.dispatchDue`, `sweepStaleSmsClaims`, `sweepUnclaimedSms`, `advanceDueSmsRegistrations`, `chargeDueSmsFees`, `chargeDueInboxFees`, `recordDueVerifySmsUsage` (bills each organization for its codes from coss.com's shared numbers, reading coss.com's messages), - `authenticateSchedulingSession` (from the token), `handleCalendarPush`, `completeCalendarAuthorization` (from the push's channel and the consent's state), `handleZoomDeauthorization` (from Zoom's signed event), `refreshIdleZoomConnections`, `runDueCalendarSyncs`, `runDueCalendarWrites`, `renewDueCalendarChannels`, `checkHostEditedEvents`, `runDueSchedulingNotifications`, `expireDueBookings`, + `authenticateSchedulingSession` (from the token), `handleCalendarPush`, `completeCalendarAuthorization` (from the push's channel and the consent's state), `handleZoomDeauthorization` (from Zoom's signed event), `refreshIdleZoomConnections`, `runDueCalendarSyncs`, `runDueCalendarWrites`, `renewDueCalendarChannels`, `checkHostEditedEvents`, `runDueSchedulingNotifications`, `runDueReminderSyncs`, `expireDueBookings`, `runDueFormDeliveries`, `runDueFormStoreCleanup` (and the dispatch jobs, from the job's `organizationId`); `@coss/sms/admin` is platform-level like `@coss/email/admin`. Server actions take only slugs + target ids and derive the org from `requireOrgPermission`; every Better Auth organization call passes `organizationId` @@ -789,7 +789,7 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu **Not built yet** (don't assume these exist; delete a line when it ships): these parts of Support: on-call paging (so 24x7 can't be sold yet), a holiday calendar, automatic SLA credits, a support role narrower than platform admin, satisfaction ratings, AI drafts, presence, iOS screens, a public API, MCP and CLI tools, -these parts of Scheduling: public booking pages, built-in payments, customers' own OAuth apps, routing forms, a workflow builder, waitlists, travel schedules, imports from Cal.com Platform, +these parts of Scheduling: public booking pages, built-in payments, customers' own OAuth apps, routing forms, a workflow builder, waitlists, travel schedules, imports from Cal.com Platform, reminders at a local time of day, reminder copy per locale, quiet hours, a built-in rating page, fixed SMS numbers as reminder recipients, sending a test reminder, undoing a no-show, a per-attendee opt-out of reminder emails, reminders in the iOS app, customers granting staff temporary access in HIPAA mode, Verify's voice and WhatsApp channels, locales other than English, custom message templates, Verify in the iOS app, Verify's shared numbers in diff --git a/apps/api/openapi.json b/apps/api/openapi.json index d97301662..d151c7347 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3665,7 +3665,10 @@ "scheduling.booking.no_show", "scheduling.booking.external_conflict", "scheduling.booking.calendar_write_failed", - "scheduling.booking.location_ready" + "scheduling.booking.location_ready", + "scheduling.booking.started", + "scheduling.booking.ended", + "scheduling.booking.reminder" ] }, "data": { @@ -3751,6 +3754,50 @@ "previous_start": { "type": "string" }, + "reminder": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "anchor": { + "type": "string", + "enum": [ + "start", + "end", + "confirmed", + "rescheduled", + "cancelled", + "declined", + "pending_approval", + "no_show" + ] + }, + "before_minutes": { + "type": [ + "number", + "null" + ] + }, + "after_minutes": { + "type": [ + "number", + "null" + ] + }, + "scheduled_for": { + "type": "string" + } + }, + "required": [ + "key", + "anchor", + "before_minutes", + "after_minutes", + "scheduled_for" + ], + "description": "Which reminder this is, on `scheduling.booking.reminder`." + }, "occurred_at": { "type": "string" } @@ -9200,12 +9247,15 @@ "scheduling.booking.external_conflict", "scheduling.booking.calendar_write_failed", "scheduling.booking.location_ready", + "scheduling.booking.started", + "scheduling.booking.ended", + "scheduling.booking.reminder", "scheduling.calendar.connected", "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected" ] }, - "maxItems": 86, + "maxItems": 89, "description": "Event types delivered to this endpoint. An empty array means every event type, including ones added later. Defaults to every event type." }, "api_version": { @@ -9515,12 +9565,15 @@ "scheduling.booking.external_conflict", "scheduling.booking.calendar_write_failed", "scheduling.booking.location_ready", + "scheduling.booking.started", + "scheduling.booking.ended", + "scheduling.booking.reminder", "scheduling.calendar.connected", "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected" ] }, - "maxItems": 86, + "maxItems": 89, "description": "Event types delivered to this endpoint. An empty array means every event type, including ones added later." }, "api_version": { @@ -10079,12 +10132,15 @@ "scheduling.booking.external_conflict", "scheduling.booking.calendar_write_failed", "scheduling.booking.location_ready", + "scheduling.booking.started", + "scheduling.booking.ended", + "scheduling.booking.reminder", "scheduling.calendar.connected", "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected" ] }, - "maxItems": 86, + "maxItems": 89, "description": "Event types to receive. Defaults to every event type." }, "api_version": { @@ -10855,6 +10911,9 @@ "scheduling.booking.external_conflict", "scheduling.booking.calendar_write_failed", "scheduling.booking.location_ready", + "scheduling.booking.started", + "scheduling.booking.ended", + "scheduling.booking.reminder", "scheduling.calendar.connected", "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected" @@ -17162,6 +17221,12 @@ "null" ] }, + "phone": { + "type": [ + "string", + "null" + ] + }, "time_zone": { "type": "string" }, @@ -17207,6 +17272,7 @@ "uid", "name", "email", + "phone", "time_zone", "attributes", "metadata", @@ -17221,6 +17287,7 @@ "uid": "doctor_482", "name": "Dr. Ana Reyes", "email": "ana@example.com", + "phone": null, "time_zone": "America/New_York", "attributes": { "licensed_in": [ @@ -17255,6 +17322,11 @@ "format": "email", "description": "Where this host's notifications go. Not an identity, and need not be unique." }, + "phone": { + "type": "string", + "pattern": "^\\+[1-9]\\d{6,14}$", + "description": "Where this host's SMS reminders go, in E.164." + }, "time_zone": { "type": "string", "minLength": 1, @@ -17347,6 +17419,7 @@ "uid": "doctor_482", "name": "Dr. Ana Reyes", "email": "ana@example.com", + "phone": null, "time_zone": "America/New_York", "attributes": { "licensed_in": [ @@ -17389,6 +17462,13 @@ "maxLength": 320, "format": "email" }, + "phone": { + "type": [ + "string", + "null" + ], + "pattern": "^\\+[1-9]\\d{6,14}$" + }, "time_zone": { "type": "string", "minLength": 1, @@ -17966,6 +18046,35 @@ ] } }, + "notifications": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "properties": { + "email_attendee": { + "type": "boolean" + }, + "email_host": { + "type": "boolean" + }, + "sms_attendee": { + "type": "boolean" + } + } + } + }, + "reminders": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SchedulingReminder" + } + }, "hosts": { "type": "array", "items": { @@ -18037,6 +18146,8 @@ "questions", "locations", "host_filter", + "notifications", + "reminders", "hosts", "metadata", "created_at", @@ -18068,6 +18179,8 @@ "questions": [], "locations": [], "host_filter": null, + "notifications": null, + "reminders": null, "hosts": [ { "host": "host_01kd2m9a0b1c2d3e4f5g6h7j8m", @@ -18351,6 +18464,149 @@ } ] }, + "SchedulingReminder": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "anchor": { + "type": "string", + "enum": [ + "start", + "end", + "confirmed", + "rescheduled", + "cancelled", + "declined", + "pending_approval", + "no_show" + ] + }, + "before_minutes": { + "type": [ + "number", + "null" + ] + }, + "after_minutes": { + "type": [ + "number", + "null" + ] + }, + "send": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email_attendee", + "email_host", + "sms_attendee", + "sms_host", + "webhook" + ] + } + }, + "email_to": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "sms_message": { + "type": [ + "string", + "null" + ] + }, + "button": { + "type": [ + "object", + "null" + ], + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "label", + "url" + ] + }, + "include_invite": { + "type": "boolean" + }, + "send_if_late": { + "type": "boolean" + }, + "skip_no_show": { + "type": "boolean" + }, + "only_if": { + "type": [ + "object", + "null" + ], + "properties": { + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "location_types": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "required": [ + "metadata", + "location_types" + ] + } + }, + "required": [ + "key", + "anchor", + "before_minutes", + "after_minutes", + "send", + "email_to", + "subject", + "message", + "sms_message", + "button", + "include_invite", + "send_if_late", + "skip_no_show", + "only_if" + ] + }, "CreateEventTypeRequest": { "type": "object", "properties": { @@ -18502,6 +18758,39 @@ }, "description": "Pool members also include active hosts matching all of these attributes." }, + "notifications": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "properties": { + "email_attendee": { + "type": "boolean" + }, + "email_host": { + "type": "boolean" + }, + "sms_attendee": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "description": "Per event, overriding your settings for this event type." + }, + "reminders": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SchedulingReminderInput" + }, + "maxItems": 10, + "description": "Replaces your settings' `reminders` for this event type. `null` inherits them; an empty list means none." + }, "metadata": { "type": "object", "additionalProperties": { @@ -18539,6 +18828,185 @@ } ] }, + "SchedulingReminderInput": { + "type": "object", + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z0-9_-]{1,64}$", + "description": "Names this reminder in webhooks and on the booking. Default: from its timing, like `start-1440`." + }, + "anchor": { + "type": "string", + "enum": [ + "start", + "end", + "confirmed", + "rescheduled", + "cancelled", + "declined", + "pending_approval", + "no_show" + ], + "description": "The moment this is timed from." + }, + "before_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 525600, + "description": "How long before the anchor. Only for `start` and `end`." + }, + "after_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 86400, + "description": "How long after the anchor, up to 60 days. Default for other anchors: 0." + }, + "send": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email_attendee", + "email_host", + "sms_attendee", + "sms_host", + "webhook" + ] + }, + "maxItems": 5, + "description": "Who hears about it. `webhook` fires `scheduling.booking.reminder`." + }, + "email_to": { + "type": "array", + "items": { + "type": "string", + "maxLength": 320, + "format": "email" + }, + "maxItems": 5, + "description": "Fixed addresses to email as well: verified members of your organization, or addresses on your verified domains (live environments)." + }, + "subject": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 200, + "description": "Your own subject, with `{{variables}}`. Needs `email_from`." + }, + "message": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 2000, + "description": "Your own words in place of the standard first line, with `{{variables}}` and no links. The booking's details stay below it. Needs `email_from`." + }, + "sms_message": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 320, + "description": "Your own text, with `{{variables}}` other than names, and no links. `Reply STOP to opt out.` is always added." + }, + "button": { + "type": [ + "object", + "null" + ], + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "https, on one of your verified domains (live environments). May use `{{booking_id}}`.", + "examples": [ + "https://example.com/feedback?booking={{booking_id}}" + ] + } + }, + "required": [ + "label", + "url" + ], + "additionalProperties": false, + "description": "One button in the email. Needs `email_from`." + }, + "include_invite": { + "type": "boolean", + "description": "Attach the calendar invitation to the emails." + }, + "send_if_late": { + "type": "boolean", + "description": "If it was booked after this reminder's moment, send it right away instead of skipping it, as long as the meeting is still ahead. Only for `before_minutes` on `start`." + }, + "skip_no_show": { + "type": "boolean", + "description": "Don't send it once the booking is marked a no-show. Default: true on `end`." + }, + "only_if": { + "type": [ + "object", + "null" + ], + "properties": { + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string", + "maxLength": 500 + }, + "description": "The booking's metadata must have all of these." + }, + "location_types": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "link", + "phone", + "in_person", + "google_meet", + "microsoft_teams", + "zoom" + ] + }, + "minItems": 1, + "maxItems": 6, + "description": "The booking's location must be one of these." + } + }, + "additionalProperties": false, + "description": "Only for bookings matching all of this, checked when it's due." + } + }, + "required": [ + "anchor" + ], + "additionalProperties": false + }, "SchedulingEventTypeHostInput": { "type": "object", "properties": { @@ -18629,6 +19097,8 @@ "questions": [], "locations": [], "host_filter": null, + "notifications": null, + "reminders": null, "hosts": [ { "host": "host_01kd2m9a0b1c2d3e4f5g6h7j8m", @@ -18790,6 +19260,39 @@ }, "description": "Pool members also include active hosts matching all of these attributes." }, + "notifications": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "properties": { + "email_attendee": { + "type": "boolean" + }, + "email_host": { + "type": "boolean" + }, + "sms_attendee": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "description": "Per event, overriding your settings for this event type." + }, + "reminders": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SchedulingReminderInput" + }, + "maxItems": 10, + "description": "Replaces your settings' `reminders` for this event type. `null` inherits them; an empty list means none." + }, "metadata": { "type": "object", "additionalProperties": { @@ -19646,6 +20149,131 @@ } ] }, + "SchedulingBookingNotificationList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingBookingNotification" + } + }, + "has_more": { + "type": "boolean" + } + }, + "required": [ + "object", + "data", + "has_more" + ], + "examples": [ + { + "object": "list", + "data": [ + { + "object": "scheduling.booking_notification", + "id": "sntf_01kd2m9a0b1c2d3e4f5g6h7j8m", + "kind": "reminder", + "reminder_key": "start-1440", + "channel": "email", + "recipient": "attendee", + "due_at": "2027-01-03T15:00:00.000Z", + "finished_at": null, + "outcome": null + } + ], + "has_more": false + } + ] + }, + "SchedulingBookingNotification": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "scheduling.booking_notification" + ] + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "`reminder` for an entry of `reminders`; otherwise a standard notification, or `started`/`ended`." + }, + "reminder_key": { + "type": [ + "string", + "null" + ] + }, + "channel": { + "type": "string", + "enum": [ + "email", + "sms", + "webhook" + ] + }, + "recipient": { + "type": "string", + "enum": [ + "attendee", + "host", + "endpoint", + "address" + ] + }, + "due_at": { + "type": "string" + }, + "finished_at": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": [ + "string", + "null" + ], + "description": "`null` while it's waiting. `sent`, or why not: `too_late`, `filtered`, `no_show`, `no_address`, `no_sender`, `stale`, `superseded`, `failed`." + } + }, + "required": [ + "object", + "id", + "kind", + "reminder_key", + "channel", + "recipient", + "due_at", + "finished_at", + "outcome" + ], + "examples": [ + { + "object": "scheduling.booking_notification", + "id": "sntf_01kd2m9a0b1c2d3e4f5g6h7j8m", + "kind": "reminder", + "reminder_key": "start-1440", + "channel": "email", + "recipient": "attendee", + "due_at": "2027-01-03T15:00:00.000Z", + "finished_at": null, + "outcome": null + } + ] + }, "UpdateBookingLocationRequest": { "type": "object", "properties": { @@ -21080,6 +21708,22 @@ } }, "description": "Per event, overriding the defaults." + }, + "email_reply_to": { + "type": [ + "string", + "null" + ] + }, + "reminders": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SchedulingReminder" + }, + "description": "What goes out around a booking, unless an event type has its own. `null`: a reminder a day before, on the channels `notifications.reminder` chooses." } }, "required": [ @@ -21091,7 +21735,9 @@ "email_from", "sms_from", "calendar_failure_mode", - "notifications" + "notifications", + "email_reply_to", + "reminders" ], "examples": [ { @@ -21109,7 +21755,9 @@ "reminder": { "sms_attendee": true } - } + }, + "email_reply_to": null, + "reminders": null } ] }, @@ -21178,6 +21826,15 @@ "last_known" ] }, + "email_reply_to": { + "type": [ + "string", + "null" + ], + "maxLength": 320, + "format": "email", + "description": "Where replies go, for emails from `email_from`." + }, "notifications": { "type": [ "object", @@ -21198,6 +21855,17 @@ }, "additionalProperties": false } + }, + "reminders": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SchedulingReminderInput" + }, + "maxItems": 10, + "description": "What goes out around a booking, unless an event type has its own. `null` goes back to a reminder a day before." } }, "additionalProperties": false, @@ -67404,184 +68072,454 @@ } } }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying.", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Unexpected server error (`api_error` / `internal_error`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/v1/scheduling/bookings/{id}": { - "get": { - "operationId": "getSchedulingBooking", - "tags": [ - "Scheduling bookings" - ], - "summary": "Retrieve a booking", - "description": "A booking with its attendees and their answers.", - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The booking's id.", - "examples": [ - "book_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The booking's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], - "responses": { - "200": { - "description": "The booking.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Coss-Version": { - "description": "The API version this response is rendered in.", - "schema": { - "type": "string", - "examples": [ - "2026-10-10" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SchedulingBooking" - } - } - } - }, - "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/scheduling/bookings/{id}": { + "get": { + "operationId": "getSchedulingBooking", + "tags": [ + "Scheduling bookings" + ], + "summary": "Retrieve a booking", + "description": "A booking with its attendees and their answers.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The booking's id.", + "examples": [ + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The booking's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], + "responses": { + "200": { + "description": "The booking.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulingBooking" + } + } + } + }, + "400": { + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/scheduling/bookings/{id}/reschedule": { + "post": { + "operationId": "rescheduleSchedulingBooking", + "tags": [ + "Scheduling bookings" + ], + "summary": "Reschedule a booking", + "description": "Moves it to a new time, keeping its id. The old time stays booked if the new one isn't free.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The booking's id.", + "examples": [ + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The booking's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RescheduleBookingRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The booking.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulingBooking" + } + } + } + }, + "400": { + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Nothing with this id or uid exists in the environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67601,8 +68539,8 @@ } } }, - "403": { - "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67622,8 +68560,8 @@ } } }, - "404": { - "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "422": { + "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67715,14 +68653,14 @@ } } }, - "/v1/scheduling/bookings/{id}/reschedule": { + "/v1/scheduling/bookings/{id}/cancel": { "post": { - "operationId": "rescheduleSchedulingBooking", + "operationId": "cancelSchedulingBooking", "tags": [ "Scheduling bookings" ], - "summary": "Reschedule a booking", - "description": "Moves it to a new time, keeping its id. The old time stays booked if the new one isn't free.", + "summary": "Cancel a booking", + "description": "Frees the time and removes the calendar event. A cancelled booking stays charged.", "security": [ { "bearerAuth": [] @@ -67746,16 +68684,6 @@ "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RescheduleBookingRequest" - } - } - } - }, "responses": { "200": { "description": "The booking.", @@ -67788,7 +68716,7 @@ } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67871,27 +68799,6 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", "headers": { @@ -67985,14 +68892,14 @@ } } }, - "/v1/scheduling/bookings/{id}/cancel": { + "/v1/scheduling/bookings/{id}/approve": { "post": { - "operationId": "cancelSchedulingBooking", + "operationId": "approveSchedulingBooking", "tags": [ "Scheduling bookings" ], - "summary": "Cancel a booking", - "description": "Frees the time and removes the calendar event. A cancelled booking stays charged.", + "summary": "Approve a booking", + "description": "Confirms a `pending_approval` booking.", "security": [ { "bearerAuth": [] @@ -68224,14 +69131,14 @@ } } }, - "/v1/scheduling/bookings/{id}/approve": { + "/v1/scheduling/bookings/{id}/decline": { "post": { - "operationId": "approveSchedulingBooking", + "operationId": "declineSchedulingBooking", "tags": [ "Scheduling bookings" ], - "summary": "Approve a booking", - "description": "Confirms a `pending_approval` booking.", + "summary": "Decline a booking", + "description": "Refuses a `pending_approval` booking and frees the time.", "security": [ { "bearerAuth": [] @@ -68463,14 +69370,14 @@ } } }, - "/v1/scheduling/bookings/{id}/decline": { - "post": { - "operationId": "declineSchedulingBooking", + "/v1/scheduling/bookings/{id}/notifications": { + "get": { + "operationId": "listSchedulingBookingNotifications", "tags": [ "Scheduling bookings" ], - "summary": "Decline a booking", - "description": "Refuses a `pending_approval` booking and frees the time.", + "summary": "List a booking's notifications", + "description": "What's been queued for this booking and what became of it, including why something wasn't sent. It never says who it went to.", "security": [ { "bearerAuth": [] @@ -68496,7 +69403,7 @@ ], "responses": { "200": { - "description": "The booking.", + "description": "The notifications.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68520,7 +69427,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingBookingNotificationList" } } } @@ -68609,27 +69516,6 @@ } } }, - "422": { - "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a8540691b..47818ee29 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,11 @@ import type { PlanGate, UsageGate } from "@coss/billing/gate"; import { db } from "@coss/db"; -import type { DomainDeps, EmailService, LinkKeyRing } from "@coss/email"; +import { + authorizeLiveSender, + type DomainDeps, + type EmailService, + type LinkKeyRing, +} from "@coss/email"; import type { InboundRuntime } from "@coss/email/runtime"; import { FakeFormFileStore, type FormFileStore, hostedPageScreening } from "@coss/forms"; import { type LogWriter, noopLogWriter } from "@coss/logs"; @@ -342,6 +347,20 @@ export function createApp(opts: CreateAppOptions) { ...opts.scheduling, billing: opts.billing, screenSetup: opts.scheduling.screenSetup ?? hostedPageScreening(opts.riskDeps), + verifiedAddress: + opts.scheduling.verifiedAddress ?? + (async ({ organizationId, address }) => { + try { + await authorizeLiveSender({ + organizationId, + from: address, + provider: domainDeps.identity.name, + }); + return true; + } catch { + return false; + } + }), }, riskDeps: opts.riskDeps, inbound, diff --git a/apps/api/src/routes/scheduling.ts b/apps/api/src/routes/scheduling.ts index ddad6dfe0..ce88adcb1 100644 --- a/apps/api/src/routes/scheduling.ts +++ b/apps/api/src/routes/scheduling.ts @@ -32,6 +32,7 @@ import { getSchedule, getSettings, getSettingsRow, + listBookingNotifications, listBookings, listConnections, listEventTypeSlots, @@ -59,6 +60,7 @@ import { AvailabilitySearchRequest, AvailabilitySearchResult, BookingList, + BookingNotificationList, BookingObject, CalendarAuthorization, CalendarConnectionList, @@ -633,6 +635,24 @@ export const noShowBookingRoute = createRoute({ }, }); +export const listBookingNotificationsRoute = createRoute({ + method: "get", + path: "/scheduling/bookings/{id}/notifications", + operationId: "listSchedulingBookingNotifications", + tags: [TAG_BOOKINGS], + summary: "List a booking's notifications", + description: + "What's been queued for this booking and what became of it, including why something wasn't sent. It never says who it went to.", + security: [{ bearerAuth: [] }], + middleware: read(), + request: { params: BookingParams }, + responses: { + 200: jsonDoc("The notifications.", BookingNotificationList), + ...commonErrors, + 404: notFound, + }, +}); + export const updateBookingLocationRoute = createRoute({ method: "patch", path: "/scheduling/bookings/{id}/location", @@ -1233,6 +1253,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA router.openapi(createEventTypeRoute, async (c) => { const eventType = await createEventType(c.var.auth, c.req.valid("json"), { screen: deps.screenSetup, + verifiedAddress: deps.verifiedAddress, }); await recordApiAudit(c, { action: "scheduling_event_type.created", @@ -1255,7 +1276,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA c.var.auth, c.req.valid("param").event_type, c.req.valid("json"), - { screen: deps.screenSetup }, + { screen: deps.screenSetup, verifiedAddress: deps.verifiedAddress }, ); await recordApiAudit(c, { action: "scheduling_event_type.updated", @@ -1410,6 +1431,17 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA ), ); + router.openapi(listBookingNotificationsRoute, async (c) => + c.json( + { + object: "list" as const, + data: await listBookingNotifications(c.var.auth, c.req.valid("param").id), + has_more: false, + }, + 200, + ), + ); + router.openapi(noShowBookingRoute, async (c) => c.json(await markNoShow(c.var.auth, c.req.valid("param").id, deps, actorOf(c.var.auth)), 200), ); @@ -1673,6 +1705,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA router.openapi(updateSettingsRoute, async (c) => { const settings = await updateSettings(c.var.auth, c.req.valid("json"), { screen: deps.screenSetup, + verifiedAddress: deps.verifiedAddress, }); await recordApiAudit(c, { action: "scheduling_settings.updated", diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index 6fe787f55..943c59083 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -289,6 +289,7 @@ describe("GET /openapi.json", () => { "/v1/scheduling/bookings/{id}/location", "/v1/scheduling/bookings/{id}/manage_sessions", "/v1/scheduling/bookings/{id}/no_show", + "/v1/scheduling/bookings/{id}/notifications", "/v1/scheduling/bookings/{id}/reschedule", "/v1/scheduling/event_types", "/v1/scheduling/event_types/{event_type}", diff --git a/apps/api/test/contract-types.ts b/apps/api/test/contract-types.ts index 0c3a67855..512ab93b3 100644 --- a/apps/api/test/contract-types.ts +++ b/apps/api/test/contract-types.ts @@ -215,6 +215,7 @@ import type { AuthorizeCalendarRequest, AvailabilitySearchRequest, AvailabilitySearchResult, + BookingNotificationObject, BookingObject, CalendarAuthorization, CalendarConnectionObject, @@ -360,6 +361,7 @@ import type { SchedulingAttendeeErasure, SchedulingAvailabilitySearchResult, SchedulingBooking, + SchedulingBookingNotification, SchedulingCalendarAuthorization, SchedulingCalendarConnection, SchedulingCancelledSeries, @@ -1107,6 +1109,9 @@ export type SubaccountContract = [ Assert, SchedulingCancelledSeries>>, Assert, SchedulingAttendeeErasure>>, Assert, SchedulingSettings>>, + Assert< + MutuallyAssignable, SchedulingBookingNotification> + >, Assert, UpdateSchedulingSettingsParams>>, Assert< MutuallyAssignable, AuthorizeSchedulingCalendarParams> diff --git a/apps/api/test/mcp-contract.test.ts b/apps/api/test/mcp-contract.test.ts index 3f83a6ba8..2f65abce6 100644 --- a/apps/api/test/mcp-contract.test.ts +++ b/apps/api/test/mcp-contract.test.ts @@ -398,6 +398,12 @@ describe("coss-mcp against the API", () => { false, ); + const reminders = await call("save_scheduling_reminders", { + reminders: [{ key: "soon", anchor: "start", before_minutes: 60, send: ["email_attendee"] }], + }); + expect(reminders.data.reminders[0].key).toBe("soon"); + expect((await call("get_scheduling_reminders", {})).data.reminders[0].skip_no_show).toBe(false); + const host = await call("save_scheduling_host", { uid: "ada", name: "Ada", diff --git a/apps/api/test/sdk-contract.test.ts b/apps/api/test/sdk-contract.test.ts index 410a5bdc0..558469e51 100644 --- a/apps/api/test/sdk-contract.test.ts +++ b/apps/api/test/sdk-contract.test.ts @@ -2038,3 +2038,84 @@ describe("coss.scheduling against the API", () => { expect(erased.error).toBeNull(); }); }); + +describe("coss.scheduling reminders against the API", () => { + test("a list round-trips, and a booking says what it queued", async () => { + const tenant = await createTestTenant(); + const { app } = createTestApp(); + const { scheduling } = clientFor(app, tenant.sandboxKey); + + const settings = await scheduling.settings.update({ + email_from: "appointments@example.com", + email_reply_to: "desk@example.com", + reminders: [ + { anchor: "start", before_minutes: 60, send: ["email_attendee", "webhook"] }, + { + key: "follow_up", + anchor: "end", + after_minutes: 120, + send: ["email_attendee"], + subject: "How did it go, {{attendee_first_name}}?", + button: { label: "Feedback", url: "https://example.com/f?b={{booking_id}}" }, + only_if: { metadata: { plan: "pro" }, location_types: ["zoom"] }, + }, + ], + }); + expect(settings.error).toBeNull(); + expect(settings.data?.email_reply_to).toBe("desk@example.com"); + expect(settings.data?.reminders?.map((reminder) => reminder.key)).toEqual([ + "start-60", + "follow_up", + ]); + expect(settings.data?.reminders?.[1]?.skip_no_show).toBe(true); + + const refused = await scheduling.settings.update({ + reminders: [ + { + anchor: "start", + before_minutes: 5, + send: ["sms_attendee"], + sms_message: "Hi {{attendee_name}}", + }, + ], + }); + expect(refused.error?.status).toBe(422); + + const host = ( + await scheduling.hosts.create({ name: "Dr. Reyes", phone: "+15550002222", time_zone: "UTC" }) + ).data; + if (!host) throw new Error("no host"); + expect(host.phone).toBe("+15550002222"); + await scheduling.schedules.create(host.id, { + rules: [{ days: [0, 1, 2, 3, 4, 5, 6], start: "00:00", end: "23:59" }], + }); + const eventType = ( + await scheduling.eventTypes.create({ + title: "Consultation", + duration_minutes: 30, + hosts: [{ host: host.id }], + notifications: { confirmed: { email_host: false } }, + reminders: [{ key: "own", anchor: "start", before_minutes: 30, send: ["email_host"] }], + }) + ).data; + if (!eventType) throw new Error("no event type"); + expect(eventType.notifications).toEqual({ confirmed: { email_host: false } }); + expect(eventType.reminders?.[0]?.key).toBe("own"); + + const start = new Date(Date.UTC(new Date().getUTCFullYear() + 1, 0, 4, 15)); + const booking = ( + await scheduling.bookings.create({ + event_type: eventType.id, + start: start.toISOString(), + attendee: { name: "Sam", email: "sam@example.com", time_zone: "UTC" }, + }) + ).data; + if (!booking) throw new Error("no booking"); + const sends = await scheduling.bookings.notifications(booking.id); + expect(sends.error).toBeNull(); + const kinds = sends.data?.data.map((row) => row.reminder_key ?? row.kind).sort(); + expect(kinds).toEqual(["confirmed", "ended", "own", "started"]); + expect(JSON.stringify(sends.data)).not.toContain("sam@example.com"); + expect((await scheduling.bookings.notifications("book_missing")).error?.status).toBe(404); + }); +}); diff --git a/apps/site/content/docs/(platform)/webhooks.mdx b/apps/site/content/docs/(platform)/webhooks.mdx index db387e844..eeb2cc0a8 100644 --- a/apps/site/content/docs/(platform)/webhooks.mdx +++ b/apps/site/content/docs/(platform)/webhooks.mdx @@ -80,6 +80,9 @@ const secret = endpoint?.secret; | `scheduling.booking.external_conflict` | Something else landed on a booked time, or the host changed the event on their calendar | | `scheduling.booking.calendar_write_failed` | A booking couldn't be written to the host's calendar. It still stands | | `scheduling.booking.location_ready` | A booking's meeting link is ready | +| `scheduling.booking.started` | A confirmed booking's start time arrived. It doesn't mean anyone joined | +| `scheduling.booking.ended` | A confirmed booking's end time arrived | +| `scheduling.booking.reminder` | A [reminder](/docs/scheduling/reminders#webhooks-at-any-moment) you timed came due (`data.reminder`) | | `scheduling.calendar.connected` | A host [connected a calendar](/docs/scheduling/calendars) | | `scheduling.calendar.needs_reauth` | A host's calendar lost access: they aren't offered until it's back | | `scheduling.calendar.disconnected` | A host disconnected a calendar | diff --git a/apps/site/content/docs/(tools)/cli.mdx b/apps/site/content/docs/(tools)/cli.mdx index d56f42014..c90d006e4 100644 --- a/apps/site/content/docs/(tools)/cli.mdx +++ b/apps/site/content/docs/(tools)/cli.mdx @@ -427,6 +427,11 @@ coss scheduling bookings --host doctor_482 --status confirmed - `coss scheduling slots --start