Repository navigation
feat(scheduling): add externally delivered scheduling - #129
KitKat31337 wants to merge 19 commits into
Conversation
|
Run failed. View the logs →
|
|
@shockalotti I... couldn't sleep... |
shockalotti
left a comment
There was a problem hiding this comment.
Reviewed the full diff — large but careful work, and the no-SSRF design (external_url stored, never fetched) is the right call. Blocking items before merge:
- Public strings are hardcoded English (invitationState, invitation-state template, duration-policy errors). New public-facing pages fall under the repo i18n rule — add locale keys across all 9 locales in internal/i18n/locales (same-keys test will enforce). Disclosure in the PR body isn't compliance.
- Land order is now concrete: #127 just merged. Rebase onto main; verify migration 00071 still sequences after #127's 00070 and resolve the event_type.go / duplicate / api.ts / editor overlaps.
- No-op duration PATCH wipes an explicit fixed policy — sending duration_minutes unchanged nulls all three bound columns via the min==max auto-clear. Gate the clear on the value actually changing.
- DeleteUser 409 on invitation references is a behavior change to an existing endpoint — needs a PR-notes/ARCHITECTURE mention, not just a code comment.
Nits: quote {{.SlotsURL}} in book.html:274/manage.html:266 (safe today, fragile); delivery CHECK admits 'calnode' with no implementing path; no idempotency key on create (disclosed, fine).
|
I also haven't had time to really test this yet, or finish the zammad side to make that easy. |
Establish the invitation resource and effective scheduling context from Calnode discussion Calnode#47 and issue Calnode#92. Snapshot duration, host roles, and scheduling windows with hashed expiring token groundwork. Keep public redemption, atomic booking claims, delivery, and webhook enrichment deferred.
e000b05 to
9b93d3e
Compare
|
Verified on the branch: full Go suite, svelte-check, embed check, i18n same-keys, and gofmt all green. The four fixes check out — locale keys across all 9 files with a real locale test, the PATCH table test covers unchanged/changed/reset cases, migration order is correct post-#127, and the DeleteUser change is documented, not just commented. One note: you mention not having tested it yourself yet. My verification covers the automated side, but a live run-through on your end (book → reschedule → cancel on an invitation, plus one RSVP round-trip if you have Resend wired) is still worth doing before this ships. Nothing here blocks merge on my side though — the moment you mark it ready, I'll merge it same-day. |
|
Yeah, I will be keeping it as draft until some live tests can be performed. |

Summary
Adds customer-specific scheduling invitations so an external system can request an appointment using a reusable Calnode event type, with an authorized duration, host selection, and scheduling window.
For example, a ticketing system can create a 60-minute invitation for a particular technician, send the returned scheduling link to the customer, and receive signed webhook events containing the resulting booking and original ticket reference.
Calnode owns availability, host assignment, booking state, and subsequent appointment management. The calling system owns initial invitation delivery and its ticket workflow.
Implements the initial Calnode scope discussed in discussion #47 and tracked in issue #92.
WIP status
This draft brings the five planned implementation slices together for review:
The branch includes the Calnode workflow from invitation creation through booking and constrained appointment management. Live Microsoft 365/Teams, email, and external adapter integration testing remain outstanding.
The Zammad adapter and ticket synchronization are separate work.
External scheduling workflow
The initial invitation is immediately active and uses external delivery. Calnode does not send the initial scheduling-request email.
Invitation resource and API
Adds authenticated endpoints using existing API-key or staff-session authentication:
POST/v1/scheduling-invitationsGET/v1/scheduling-invitationsGET/v1/scheduling-invitations/{id}GET/v1/scheduling-invitations/{id}/slotsPOST/v1/scheduling-invitations/{id}/cancelInvitation access is scoped to the creator, who must own the source event type. Host membership or workspace administrator status alone does not grant access to another owner’s invitations.
Active private event types are supported. Paid event types are rejected in this initial implementation.
Public scheduling uses:
/s/{token}GET /v1/schedule/{token}/slotsPOST /v1/schedule/{token}/bookThe raw token and scheduling URL are returned only at issuance. Subsequent authenticated reads do not expose or reconstruct them.
Duration policies
Adds optional minimum, maximum, and increment fields to event types, with controls in the existing event-type editor.
Invitation duration overrides must satisfy the effective policy. Invalid values are rejected without rounding or clamping.
Existing event types retain fixed-duration behavior when the policy fields are unset. Ordinary public bookings continue to use the event type’s default duration.
The implementation also handles partial updates, explicit policy resets, event-type duplication, and legacy duration-only updates to explicitly fixed policies. Appointment duration remains separate from slot interval.
Retained constraints and shared availability
Each invitation retains its authorized recipient, duration, slot interval, buffers, host roles and routing strategy, scheduling window, location behavior, and external reference.
A supplied
host_idmust identify an active member of the event type’s host set and is retained as a required host. Without that restriction, the invitation snapshots the existing required, rotation, and optional host configuration.Availability, booking, and rescheduling use the existing slot engine with the effective invitation configuration. Working hours, date overrides, calendar conflicts, existing bookings, and intake questions remain live inputs.
Current notice and future-booking policies intersect the retained limits using the stricter values. Archived or disabled event types and archived required hosts prevent new bookings and rescheduling.
Date-only window bounds represent inclusive calendar dates in the supplied IANA timezone. Conversion uses calendar-date arithmetic to handle daylight-saving transitions. Precise RFC3339 bounds are also supported.
The entire appointment must fit inside the authorized window. Windows do not bypass working hours, notice requirements, future limits, or conflicts.
Single-use booking and appointment management
Booking submission loads authoritative values from the invitation. Customers cannot replace its recipient, duration, hosts, event type, window, or external metadata.
External calendars are checked before the booking transaction. Local availability and conflicts are then rechecked on the transaction connection using the shared slot engine.
The transaction:
booked.A unique invitation-to-booking index provides an additional single-use invariant. Concurrent token redemption and competing local bookings are covered by regression tests.
Existing calendar, meeting, confirmation, and reminder work runs after commit using the persisted appointment interval.
Subsequent management uses the existing booking-management credential. Rescheduling retains duration, assigned hosts, window, and external correlation, and rechecks live availability. Host reassignment of invitation bookings is rejected.
Cancelling a booking leaves its original invitation booked and consumed. A new appointment request requires a new invitation.
The branch also addresses spanning-booking and retained-buffer conflict checks, and returns a conflict response for invitation rescheduling races.
Signed lifecycle and booking webhooks
Adds these subscription events to the existing webhook API and editor:
scheduling_invitation.createdscheduling_invitation.bookedscheduling_invitation.cancelledscheduling_invitation.expiredRelated booking creation, rescheduling, and cancellation events include invitation correlation, effective duration, appointment interval, assigned hosts, available location information, and generic external metadata such as a ticket reference.
Invitation lifecycle events reach the creator’s subscriptions. Related booking events reach both the primary host’s and creator’s subscriptions, with deduplication when they are the same owner.
Lifecycle transitions capture payloads in a transactional outbox. The worker expires eligible invitations and moves pending events into the existing signed delivery and retry jobs.
Delivery is at least once, with stable event and delivery identifiers for receiver deduplication. Ordering is not guaranteed. A booked event can arrive before booking creation delivery or generated meeting-link availability.
Existing booking side-effect enqueueing remains best effort after commit. The remaining commit-to-enqueue recovery gap is tracked in the fork’s booking-event durability issue.
Credential handling
Scheduling tokens contain 32 cryptographically random bytes and are stored only as SHA-256 hashes.
Credential endpoints include rate limits, request-log token redaction, no-store responses, no-referrer policy, and noindex/nofollow headers.
Invitation and related management pages use a restrictive CSP and omit analytics, arbitrary injected head scripts, assistants, Markdown embeds, and remote images. Same-origin branding and existing intake components are reused.
Public responses do not expose external ticket metadata, and webhook payloads do not contain scheduling credentials.
The link grants scheduling authority for its named recipient; it does not independently verify the visitor’s identity.
Database migrations
Adds:
00071_scheduling_invitations.sql00072_invitation_booking_lifecycle.sqlThese numbers reserve migration
00070for directory-order PR #127, which is assumed to land first. The directory feature remains separate from this PR.Migration tests cover populated databases at versions 69, 70, and 71, data preservation, lifecycle backfills, uniqueness constraints, rollback, and re-upgrade.
Earlier experimental invitation builds used versions
00070/00071. Their databases require a separate compatibility upgrade if their data must be retained; their applied Goose history must not be rewritten.Test coverage and documentation
Adds regression coverage for ownership, duration policies, retained host configuration, timezone and DST boundaries, whole-appointment window enforcement, token states, submission tampering, concurrent consumption, competing bookings, management constraints, rescheduling races, signed webhook correlation, expiration processing, credential-log redaction, and migrations.
Updates the changelog and architecture documentation, and adds
docs/SCHEDULING_INVITATIONS.mdwith API examples, receiver guidance, snapshot semantics, migration coordination, and current limitations.Remaining review and integration work
Before deployment, verify live Microsoft 365/Teams meeting creation, all-host calendar updates and cancellation, confirmation and reminder delivery, and the separately implemented ticket adapter.
External calendars can change between their final availability check and database commit. Local Calnode conflicts and invitation consumption are transactional; external provider operations retain the existing best-effort boundary.
Deferred scope includes verification codes, token replacement, draft/edit/activation flows, initial invitation emails, payments, a polished invitation-management dashboard, ticket adapters and synchronization, and invitation retention/purging.
Creation has no idempotency key in this initial API. Lost issuance responses cannot recover the scheduling credential. New invitation terminal and error messages are currently English.
Refs #92.