Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c56443f
feat(invitation): add scheduling snapshots and availability
KitKat31337 Oct 2, 2026
96381cb
feat(invitation): complete duration policy editing and reset semantics
KitKat31337 Oct 2, 2026
a87ee86
feat(invitation): enforce retained constraints and atomic booking claims
KitKat31337 Oct 2, 2026
1d93630
feat(invitation): add secure public booking and constrained management
KitKat31337 Oct 2, 2026
d8e1599
feat(invitation): dispatch signed lifecycle and correlated booking we…
KitKat31337 Oct 2, 2026
15f9a86
test(invitation): strengthen authorization and webhook correlation co…
KitKat31337 Oct 2, 2026
6e50fe1
fix(scheduling): include spanning appointments and retained buffers
KitKat31337 Oct 2, 2026
7732b92
fix(invitation): retain empty location snapshots on management pages
KitKat31337 Oct 2, 2026
656d300
docs(invitation): document external scheduling workflow and review li…
KitKat31337 Oct 2, 2026
3f2c63f
fix(invitation): return conflicts for rescheduling races
KitKat31337 Oct 2, 2026
48eb295
fix(db): reserve directory ordering migration before invitations
KitKat31337 Oct 2, 2026
c79362e
test(db): preserve upstream migration during invitation rollback
KitKat31337 Oct 2, 2026
5d238c2
fix(event-types): preserve fixed policy on unchanged duration patch
KitKat31337 Oct 13, 2026
a8c2566
fix(invitation): enforce external delivery after upstream migrations
KitKat31337 Oct 13, 2026
517af5b
fix(invitation): quote slot URLs in public templates
KitKat31337 Oct 13, 2026
5238c99
fix(invitation): localize public states and scheduling feedback
KitKat31337 Oct 13, 2026
068fb70
fix(invitation): retain user references during deletion
KitKat31337 Oct 13, 2026
15f22fc
fix(event-types): reuse checkbox for invitation duration policy
KitKat31337 Oct 13, 2026
9b93d3e
docs(invitation): clarify revised scheduling and deletion behavior
KitKat31337 Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
## [Unreleased]

### Added
- **Externally delivered scheduling invitations** ([#92](https://github.com/Calnode/calnode/issues/92)).
Event owners can issue a customer-specific, expiring, single-use scheduling URL
through authenticated APIs, with permitted duration overrides, an eligible fixed
host and a date window. The booking and management pages enforce retained
constraints; signed lifecycle and booking webhooks carry external ticket
references. Duration policies are configurable in the event-type editor;
unchanged duration-only PATCH requests preserve explicit fixed bounds.
Invitation terminal pages, booking/management errors and duration feedback
use every shipped public locale. Initial invitation delivery is external-only
in both the API and schema.
Initial delivery stays with the caller; verification codes, replacement tokens,
payments and the ticket-system adapter remain deferred. See
[the integration walkthrough](docs/SCHEDULING_INVITATIONS.md).
- **Member deletion retains scheduling invitation references.**
`DELETE /v1/users/{id}` returns HTTP 409 for invitation creators, retained
hosts of any role, and owners of referenced source event types. Draft, active
and terminal invitations all keep these references for authorized constraints,
booking management and correlation history. Archive the member instead,
subject to existing role and upcoming-booking guards; cancelling an invitation
does not make its referenced users deletable.
- **Calendar invites can come from Calnode instead of the host's calendar.** A new
per-event-type setting, *Calendar invite → Sent by*. The default is unchanged: the host's
connected calendar invites the booker, from the host's own address. Choose *Calnode* and
Expand Down
92 changes: 91 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ partial unique index covers) — no TOCTOU between concurrent bookings.
**not enforced**); `team_id` — vestigial for routing), `event_type_hosts`
(the host-roles table), `event_type_questions` (intake form),
`event_type_reminders` (per-ET `hours_before`, UNIQUE).
- **Scheduling invitations:** `scheduling_invitations`,
`scheduling_invitation_hosts`, `scheduling_invitation_tokens` (migration 00071),
plus booking correlation and the transactional lifecycle outbox (00072).
Separate from account `invite_tokens` and calendar `invite_delivery`; see §8.
- **Availability:** `availability_rules` (weekly), `availability_overrides` (dated).
- **Bookings:** `bookings` (primary `host_id`, `external_event_id`, status),
`booking_hosts` (every attending host + `is_primary` + per-host
Expand Down Expand Up @@ -221,6 +225,17 @@ the platform/recovery secret doesn't expose secrets.
credential material behind.
Archiving is blocked while the member has upcoming (primary-host) bookings; a
resolve-meetings flow makes the admin reassign/cancel each first.
- **Hard deletion with invitation references returns HTTP 409**
(`DELETE /v1/users/{id}`, `users.go: DeleteUser`). An invitation’s `created_by`,
any retained `scheduling_invitation_hosts.user_id` (required, rotation or optional),
or ownership of its source event type blocks deletion. In the last case, deleting
the user would cascade to an event type still referenced by
`scheduling_invitations.event_type_id`. These foreign keys preserve the authorized
snapshot, booking management, audit history and webhook correlation. There is no
status exemption: draft, active, booked, cancelled and expired invitations all
retain their references. Use `POST /v1/users/{id}/archive`, subject to the existing
role and upcoming-booking guards, to offboard a referenced member while keeping
those records valid. Invitation retention/purging is deferred.

---

Expand Down Expand Up @@ -275,7 +290,8 @@ Booking and rescheduling pages and the embed widget group the displayed starts i

- Engine: `internal/slots/generate.go`. Input: `[]HostAvailability` (rules,
overrides, busy intervals, **Role**), `EventConfig` (duration, interval, buffers,
min-notice, max-future, `RoutingMode`), date range, booker tz, injectable `Now`.
min-notice, max-future, `RoutingMode`), date range, booker tz, injectable `Now`,
and an optional `AllowedWindow` bounding the whole appointment.
- Computes per-host free windows per UTC day, intersects/subtracts busy, aligns to
the slot interval, then `pickHosts` decides per-slot which hosts to surface:
- `collective`: all hosts must be free → return all.
Expand Down Expand Up @@ -314,6 +330,67 @@ Booking and rescheduling pages and the embed widget group the displayed starts i
fetch window is widened ±1–2 days so it isn't missed (regression-tested in
`slots_busy_test.go`).

### Scheduling invitations

The accepted direction is [discussion #47](https://github.com/Calnode/calnode/discussions/47)
and [issue #92](https://github.com/Calnode/calnode/issues/92). Event types provide
reusable defaults/policies; invitations retain authorized request values; bookings
retain the selected interval and assigned hosts. The external adapter creates an
invitation, sends its returned scheduling URL, and receives signed correlated events.
See [SCHEDULING_INVITATIONS.md](SCHEDULING_INVITATIONS.md) for API examples,
authorization, snapshot semantics, date boundaries and integration limitations.

- Owner-authenticated create/list/read/cancel APIs use `/v1/scheduling-invitations`.
Creation returns an opaque `/s/{token}` URL once. Only external initial delivery
is supported; other modes are rejected. Private active event types are eligible,
paid event types are not. Tokens are 32 random bytes, with SHA-256-only persistence.
- Optional event-type min/max/increment policies authorize invitation duration
overrides. Defaults remain fixed duration. Partial PATCH validates the complete
effective policy; an all-null reset restores fixed duration. Duration-only legacy
updates clear explicit fixed bounds only when the duration actually changes;
unchanged or omitted durations preserve them. Public event-type booking
behavior, duplication and existing API conventions are preserved.
- `schedulingContext` resolves event defaults or invitation snapshots for the same
slot engine. Invitations retain duration, interval, buffers, recipient, host
roles/routing, location and external reference. `host_id` restricts to one active
eligible required host; omission snapshots normal required/rotation/optional
routing. Current hours/calendar/bookings stay live; current notice/future limits
intersect their snapshots. Archived/disabled sources or required hosts fail closed.
- Date-only bounds are inclusive local dates, ending at midnight after the final
date via calendar arithmetic (DST-safe). Precise RFC3339 bounds are also accepted.
The entire appointment fits; buffers apply to busy intervals and may extend beyond
the date window. Listing, booking and rescheduling use the same constraints.
- Public `/v1/schedule/{token}/slots` and `/book` load authoritative values from
the invitation. Submission rejects recipient/duration/host/window tampering,
rechecks external calendars before opening a database transaction, then runs the
shared slot engine again against transaction-local hours/bookings. The existing
booking service assigns hosts and atomically inserts the booking, conditionally
marks the invitation booked, and consumes the credential. A unique booking FK
provides a second single-use invariant (migration 00072).
- Existing calendar/meeting/email/reminder side effects use the persisted interval
after commit. Management uses the existing booking credential, retaining duration,
assigned hosts, window and correlation after invitation consumption/expiration.
Cancellation never reopens the original invitation; reassignment is rejected.
- Invitation transitions write a payload snapshot to a transactional outbox. The
worker expires eligible active invitations once and drains outbox records into
existing signed delivery/retry jobs. Correlated booking events also reach the
creator, even when the primary host differs. See §13 for delivery semantics.
- Credential pages use no-referrer/no-store/noindex, rate limits, log redaction,
same-origin resources and a restrictive CSP. Analytics, head injection, assistants,
remote images and Markdown embeds are omitted; safe branding/intake components
are reused. Public payloads omit integration/ticket metadata. Terminal pages and
invitation booking/management errors use the public locale resolver and locale
tables; valid `?lang=` choices are persisted even on terminal pages. Booking
submissions may supply `language`, which also determines the retained attendee
locale for confirmations/reminders. Duration-policy feedback is localized.

Verification codes, replacement tokens, draft/edit/activation, initial email
delivery, payments, ticket adapters/synchronization and retention policy remain
deferred. The merged directory-order PR #127 owns 00070. The unmerged
invitation foundation/lifecycle migrations use 00071/00072. Development uses clean
databases; databases from the earlier experimental 00070/00071 numbering require
a separate compatibility upgrade and must not have their Goose history rewritten.

### Client calendar perf (book.html / manage.html)

- **Optimistic render:** the calendar paints immediately treating every in-window
Expand Down Expand Up @@ -798,6 +875,19 @@ as the desired state:
(`buildData`) at enqueue time, so each subscriber gets its own `data`. New webhooks
default to all fields ticked (self-hoster unticks what they don't want); a
delivery-log view (status/HTTP/attempts) is in the admin webhooks page.
- **Scheduling invitation lifecycle:** `.created`, `.booked`, `.cancelled`, `.expired`
events use the existing envelope and signing system. State transitions capture
payloads in `scheduling_invitation_events`; the worker drains them atomically into
delivery/jobs rows and expires active records conditionally. Related booking
events always include invitation/booking/event IDs, duration, intervals, assigned
hosts, available location and generic external reference; they also reach the
invitation creator's subscriptions. Ordinary booking payloads remain unchanged.
New invitation/correlated envelopes have stable event IDs; retries retain stored
payloads and delivery IDs. Delivery is at least once and ordering is not guaranteed
across asynchronous side effects/outbox/retries. A booked event may precede meeting
link creation. Existing booking side-effect enqueueing remains best effort; once
enqueued, existing durable jobs/retries handle delivery. Raw scheduling tokens/URLs
never enter webhook payloads. Full receiver guidance is in the integration doc.

---

Expand Down
Loading
Loading