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 96c8ef926..4f8b0c29a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -532,7 +532,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`, `chargeDueHostFees`, `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), `runDueCalendarDelegations`, `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), `runDueCalendarDelegations`, `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` @@ -796,7 +796,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): OAuth for the hosted MCP server (it takes an API key in a header) and `COSS_MCP_TOOLSETS` there, 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, these parts of calendar delegation: automatic Microsoft secret rotation, coss.com-owned or keyless (`signJwt`) service accounts, certificate credentials, account-level Zoom, excluding one host while `match_host_email` is on, and delegation in the iOS app, MCP and CLI, customers granting staff temporary access in HIPAA mode, Verify's voice and WhatsApp channels, locales other than English, custom message templates, diff --git a/apps/api/openapi.json b/apps/api/openapi.json index d470d8b15..a552227d8 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3829,7 +3829,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" ], "description": "What happened to the booking." }, @@ -3925,6 +3928,55 @@ "type": "string", "description": "`scheduling.booking.rescheduled`: when it used to start." }, + "reminder": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Which reminder this is." + }, + "anchor": { + "type": "string", + "enum": [ + "start", + "end", + "confirmed", + "rescheduled", + "cancelled", + "declined", + "pending_approval", + "no_show" + ], + "description": "The moment it's timed from." + }, + "before_minutes": { + "type": [ + "number", + "null" + ], + "description": "How long before the anchor. `null` when it's after." + }, + "after_minutes": { + "type": [ + "number", + "null" + ], + "description": "How long after the anchor. `null` when it's before." + }, + "scheduled_for": { + "type": "string", + "description": "When it was due." + } + }, + "required": [ + "key", + "anchor", + "before_minutes", + "after_minutes", + "scheduled_for" + ], + "description": "`scheduling.booking.reminder`: which reminder this is." + }, "occurred_at": { "type": "string", "description": "When this happened." @@ -9677,6 +9729,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", @@ -9684,7 +9739,7 @@ "scheduling.calendar_delegation.restored" ] }, - "maxItems": 88, + "maxItems": 91, "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": { @@ -9997,6 +10052,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", @@ -10004,7 +10062,7 @@ "scheduling.calendar_delegation.restored" ] }, - "maxItems": 88, + "maxItems": 91, "description": "Event types delivered to this endpoint. An empty array means every event type, including ones added later." }, "api_version": { @@ -10570,6 +10628,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", @@ -10577,7 +10638,7 @@ "scheduling.calendar_delegation.restored" ] }, - "maxItems": 88, + "maxItems": 91, "description": "Event types to receive. Defaults to every event type." }, "api_version": { @@ -11370,6 +11431,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", @@ -17964,6 +18028,13 @@ ], "description": "Where this host's notifications go. `null` when none is set. Not an identity, and need not be unique." }, + "phone": { + "type": [ + "string", + "null" + ], + "description": "Where this host's SMS reminders go, in E.164. `null` when none is set." + }, "calendar_account": { "type": [ "string", @@ -18034,6 +18105,7 @@ "uid", "name", "email", + "phone", "calendar_account", "time_zone", "locale", @@ -18051,6 +18123,7 @@ "uid": "doctor_482", "name": "Dr. Ana Reyes", "email": "ana@example.com", + "phone": null, "calendar_account": null, "time_zone": "America/New_York", "locale": null, @@ -18167,6 +18240,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." + }, "calendar_account": { "type": [ "string", @@ -18291,6 +18369,7 @@ "uid": "doctor_482", "name": "Dr. Ana Reyes", "email": "ana@example.com", + "phone": null, "calendar_account": null, "time_zone": "America/New_York", "locale": null, @@ -18343,6 +18422,14 @@ "format": "email", "description": "Where this host's notifications go. `null` removes it." }, + "phone": { + "type": [ + "string", + "null" + ], + "pattern": "^\\+[1-9]\\d{6,14}$", + "description": "Where this host's SMS reminders go, in E.164. `null` removes it." + }, "calendar_account": { "type": [ "string", @@ -19009,6 +19096,37 @@ }, "description": "Pool members also include active hosts matching all of these attributes. `null`: only `hosts`." }, + "notifications": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "properties": { + "email_attendee": { + "type": "boolean" + }, + "email_host": { + "type": "boolean" + }, + "sms_attendee": { + "type": "boolean" + } + } + }, + "description": "Per event, overriding your settings for this event type. `null`: only those." + }, + "reminders": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SchedulingReminder" + }, + "description": "What goes out around a booking, in place of your settings' list. `null` inherits it; an empty list means none." + }, "hosts": { "type": "array", "items": { @@ -19090,6 +19208,8 @@ "questions", "locations", "host_filter", + "notifications", + "reminders", "hosts", "metadata", "created_at", @@ -19125,6 +19245,8 @@ "questions": [], "locations": [], "host_filter": null, + "notifications": null, + "reminders": null, "hosts": [ { "host": "host_01kd2m9a0b1c2d3e4f5g6h7j8m", @@ -19422,6 +19544,167 @@ ], "description": "Where a booking meets. `link`, `phone` and `in_person` carry their details, and on an event type, left blank, use the booked host's own if they have one; `google_meet`, `microsoft_teams` and `zoom` get a meeting made on the host's connected account." }, + "SchedulingReminder": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "What webhooks and the booking call this reminder." + }, + "anchor": { + "type": "string", + "enum": [ + "start", + "end", + "confirmed", + "rescheduled", + "cancelled", + "declined", + "pending_approval", + "no_show" + ], + "description": "The moment this is timed from." + }, + "before_minutes": { + "type": [ + "number", + "null" + ], + "description": "How long before the anchor. `null` when it's after." + }, + "after_minutes": { + "type": [ + "number", + "null" + ], + "description": "How long after the anchor. `null` when it's before." + }, + "send": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "email_attendee", + "email_host", + "sms_attendee", + "sms_host", + "webhook" + ] + }, + "description": "Who hears about it. `webhook` fires `scheduling.booking.reminder`." + }, + "email_to": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fixed addresses that are emailed as well." + }, + "subject": { + "type": [ + "string", + "null" + ], + "description": "Your own subject. `null`: the standard one." + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Your own words in the email. `null`: the standard ones." + }, + "sms_message": { + "type": [ + "string", + "null" + ], + "description": "Your own text. `null`: the standard one." + }, + "button": { + "type": [ + "object", + "null" + ], + "properties": { + "label": { + "type": "string", + "description": "What the button says." + }, + "url": { + "type": "string", + "description": "Where it goes." + } + }, + "required": [ + "label", + "url" + ], + "description": "One button in the email. `null`: none." + }, + "include_invite": { + "type": "boolean", + "description": "Whether the emails carry the invitation." + }, + "send_if_late": { + "type": "boolean", + "description": "Whether it goes right away if booked after its moment, instead of being skipped." + }, + "skip_no_show": { + "type": "boolean", + "description": "Whether it's dropped once the booking is marked a no-show." + }, + "only_if": { + "type": [ + "object", + "null" + ], + "properties": { + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + }, + "description": "The booking's metadata must have all of these. `null`: any." + }, + "location_types": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "The booking's location must be one of these. `null`: any." + } + }, + "required": [ + "metadata", + "location_types" + ], + "description": "Only for bookings matching all of this. `null`: every booking." + } + }, + "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": { @@ -19618,6 +19901,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": { @@ -19656,6 +19972,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}}` in its path or query.", + "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": { @@ -19750,6 +20245,8 @@ "questions": [], "locations": [], "host_filter": null, + "notifications": null, + "reminders": null, "hosts": [ { "host": "host_01kd2m9a0b1c2d3e4f5g6h7j8m", @@ -19955,6 +20452,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": { @@ -21161,6 +21691,131 @@ "occurred_at" ] }, + "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": { @@ -23081,6 +23736,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": [ @@ -23092,7 +23763,9 @@ "email_from", "sms_from", "calendar_failure_mode", - "notifications" + "notifications", + "email_reply_to", + "reminders" ], "examples": [ { @@ -23110,7 +23783,9 @@ "reminder": { "sms_attendee": true } - } + }, + "email_reply_to": null, + "reminders": null } ] }, @@ -23210,6 +23885,26 @@ "additionalProperties": false }, "description": "Per event (`confirmed`, `pending_approval`, `rescheduled`, `cancelled`, `declined`, `reminder`), overriding the defaults. `null` restores them." + }, + "email_reply_to": { + "type": [ + "string", + "null" + ], + "maxLength": 320, + "format": "email", + "description": "Where replies go, for emails from `email_from`." + }, + "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, @@ -70768,14 +71463,284 @@ } } }, - "/v1/scheduling/bookings/{id}/reschedule": { + "/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.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "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": { + "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}/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": [] @@ -70800,11 +71765,11 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RescheduleBookingRequest" + "$ref": "#/components/schemas/CancelSchedulingBookingRequest" } } } @@ -70841,7 +71806,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.", @@ -70924,27 +71889,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": { @@ -71038,14 +71982,14 @@ } } }, - "/v1/scheduling/bookings/{id}/cancel": { - "post": { - "operationId": "cancelSchedulingBooking", + "/v1/scheduling/bookings/{id}/events": { + "get": { + "operationId": "listSchedulingBookingEvents", "tags": [ "Scheduling bookings" ], - "summary": "Cancel a booking", - "description": "Frees the time and removes the calendar event. A cancelled booking stays charged.", + "summary": "List a booking's history", + "description": "What's happened to a booking, oldest first, and who did it. There's no personal data in here.", "security": [ { "bearerAuth": [] @@ -71065,23 +72009,34 @@ "name": "id", "in": "path" }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The last id of the previous page." + }, + "required": false, + "description": "The last id of the previous page.", + "name": "after", + "in": "query" + }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CancelSchedulingBookingRequest" - } - } - } - }, "responses": { "200": { - "description": "The booking.", + "description": "The events.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71105,7 +72060,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingBookingEventList" } } } @@ -71194,27 +72149,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": { @@ -71287,14 +72221,14 @@ } } }, - "/v1/scheduling/bookings/{id}/events": { - "get": { - "operationId": "listSchedulingBookingEvents", + "/v1/scheduling/bookings/{id}/approve": { + "post": { + "operationId": "approveSchedulingBooking", "tags": [ "Scheduling bookings" ], - "summary": "List a booking's history", - "description": "What's happened to a booking, oldest first, and who did it. There's no personal data in here.", + "summary": "Approve a booking", + "description": "Confirms a `pending_approval` booking.", "security": [ { "bearerAuth": [] @@ -71314,34 +72248,13 @@ "name": "id", "in": "path" }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "The last id of the previous page." - }, - "required": false, - "description": "The last id of the previous page.", - "name": "after", - "in": "query" - }, { "$ref": "#/components/parameters/CossVersion" } ], "responses": { "200": { - "description": "The events.", + "description": "The booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71365,7 +72278,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBookingEventList" + "$ref": "#/components/schemas/SchedulingBooking" } } } @@ -71454,6 +72367,27 @@ } } }, + "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": { @@ -71526,14 +72460,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": [] @@ -71765,14 +72699,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, newest first, including why something wasn't sent. It never says who it went to.", "security": [ { "bearerAuth": [] @@ -71792,13 +72726,34 @@ "name": "id", "in": "path" }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The last id of the previous page." + }, + "required": false, + "description": "The last id of the previous page.", + "name": "after", + "in": "query" + }, { "$ref": "#/components/parameters/CossVersion" } ], "responses": { "200": { - "description": "The booking.", + "description": "The notifications.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71822,7 +72777,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingBookingNotificationList" } } } @@ -71911,27 +72866,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 ab67223ca..fdd674d4d 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"; @@ -369,6 +374,20 @@ export function createApp(opts: CreateAppOptions) { billing: opts.billing, plans: opts.plans, 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 4793d7ac7..9d9aaaac7 100644 --- a/apps/api/src/routes/scheduling.ts +++ b/apps/api/src/routes/scheduling.ts @@ -39,6 +39,7 @@ import { getSettings, getSettingsRow, listBookingEvents, + listBookingNotifications, listBookings, listCalendarDelegations, listConnections, @@ -71,6 +72,7 @@ import { AvailabilitySearchResult, BookingEventList, BookingList, + BookingNotificationList, BookingObject, CalendarAuthorization, CalendarConnectionList, @@ -767,6 +769,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, newest first, including why something wasn't sent. It never says who it went to.", + security: [{ bearerAuth: [] }], + middleware: read(), + request: { params: BookingParams, query: PageQuery }, + responses: { + 200: jsonDoc("The notifications.", BookingNotificationList), + ...commonErrors, + 404: notFound, + }, +}); + export const updateBookingLocationRoute = createRoute({ method: "patch", path: "/scheduling/bookings/{id}/location", @@ -1558,6 +1578,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, plans: deps.plans, }); await recordApiAudit(c, { @@ -1581,7 +1602,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA c.var.auth, c.req.valid("param").event_type, c.req.valid("json"), - { screen: deps.screenSetup, plans: deps.plans }, + { screen: deps.screenSetup, verifiedAddress: deps.verifiedAddress, plans: deps.plans }, ); await recordApiAudit(c, { action: "scheduling_event_type.updated", @@ -1791,6 +1812,15 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA ), ); + router.openapi(listBookingNotificationsRoute, async (c) => { + const page = await listBookingNotifications( + c.var.auth, + c.req.valid("param").id, + c.req.valid("query"), + ); + return c.json({ object: "list" as const, data: page.data, has_more: page.hasMore }, 200); + }); + router.openapi(noShowBookingRoute, async (c) => c.json(await markNoShow(c.var.auth, c.req.valid("param").id, deps, actorOf(c.var.auth)), 200), ); @@ -2142,6 +2172,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/agent-surface-exemptions.ts b/apps/api/test/agent-surface-exemptions.ts index 5d6a5604e..16221e3ba 100644 --- a/apps/api/test/agent-surface-exemptions.ts +++ b/apps/api/test/agent-surface-exemptions.ts @@ -57,7 +57,6 @@ export const AGENT_SURFACE_EXEMPTIONS: Readonly> = { "POST /v1/sign/templates/{id}/builder_sessions": CHANGE, "DELETE /v1/forms/{id}/submissions/{submission_id}": REMOVES, "GET /v1/forms/{id}/submissions/{submission_id}/files/{file_id}": BYTES, - "POST /v1/scheduling/bookings/{id}/no_show": CHANGE, "PATCH /v1/scheduling/bookings/{id}/location": CHANGE, "POST /v1/scheduling/holds": CHANGE, "POST /v1/scheduling/holds/{id}/confirm": CHANGE, @@ -77,7 +76,6 @@ export const AGENT_SURFACE_EXEMPTIONS: Readonly> = { "DELETE /v1/scheduling/hosts/{host}/calendar_connections/{id}": REMOVES, "PATCH /v1/scheduling/hosts/{host}/calendars/{calendar_id}": CHANGE, "PUT /v1/scheduling/hosts/{host}/simulated_busy": CHANGE, - "PATCH /v1/scheduling/settings": CHANGE, "PATCH /v1/tracking_settings/default": CHANGE, "DELETE /v1/tracking_settings/default": REMOVES, "PATCH /v1/tracking_settings/categories/{category}": CHANGE, diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index c123b991c..f5a7dac8b 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -291,6 +291,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/calendar_delegations", "/v1/scheduling/calendar_delegations/{id}", diff --git a/apps/api/test/contract-types.ts b/apps/api/test/contract-types.ts index 9022499a1..5de676183 100644 --- a/apps/api/test/contract-types.ts +++ b/apps/api/test/contract-types.ts @@ -216,6 +216,7 @@ import type { AvailabilitySearchRequest, AvailabilitySearchResult, BookingEventObject, + BookingNotificationObject, BookingObject, CalendarAuthorization, CalendarConnectionObject, @@ -376,6 +377,7 @@ import type { SchedulingAvailabilitySearchResult, SchedulingBooking, SchedulingBookingEvent, + SchedulingBookingNotification, SchedulingCalendarAuthorization, SchedulingCalendarConnection, SchedulingCalendarDelegation, @@ -1141,6 +1143,9 @@ export type SubaccountContract = [ >, Assert, { email: string }>>, 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 730ac376e..47bbbaadb 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 911d125b2..fe1337c35 100644 --- a/apps/api/test/sdk-contract.test.ts +++ b/apps/api/test/sdk-contract.test.ts @@ -2057,3 +2057,84 @@ describe("coss.scheduling against the API", () => { expect(erasedByEmail.data?.erased).toBe(0); }); }); + +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 5c519a2d8..a6c39e581 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 16b084bff..08701004a 100644 --- a/apps/site/content/docs/(tools)/cli.mdx +++ b/apps/site/content/docs/(tools)/cli.mdx @@ -629,6 +629,11 @@ coss scheduling bookings --host doctor_482 --status confirmed - `coss scheduling slots explain --start