Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ All notable changes to Calnode are recorded here. The format follows
exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrades.
`1.0.0` will mark the point at which the API and schema are declared stable.

## [Unreleased]

### Added
- **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
the booker's invite comes from your workspace's email sender (Settings → Email) instead,
so no host's personal address reaches them, and a team can book under one name. Hosts
still get the meeting, with its Meet/Teams link, on their own calendar. Needs email set
up. Existing bookings keep the way they were invited.

## [0.10.1] - 2026-09-29

### Added
Expand Down
15 changes: 15 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,21 @@ CalDAV without iTIP scheduling (RFC 6638) does **not** auto-invite, so a future
provider would want the `.ics` — the rule is "no destination whose provider
auto-delivers invites," not "no Google."

**Who sends the invite: `invite_delivery` (migration 00068).** Per event type, copied onto
each booking at creation. `calendar` (default) is everything above: the booker is a guest
on each host's event and the provider invites them, from the host's own address.
`calnode` writes the hosts' events **without guests** (`calendarInvitee` returns "", which
every provider reads as "no attendee", so nobody is emailed from a host's account) and
always attaches Calnode's `.ics` to the booker's emails, with `ORGANIZER` set to the
instance sender from Settings → Email (`applyInviteDelivery`, `BookingData.HideHostInInvite`).
No sender address means no `ORGANIZER` line, never a fallback to the host. Host copies keep
the host as organizer and, for a `calnode` booking, leave the booker out entirely and go out as
`METHOD:PUBLISH` (`applyHostInvite`, `ICSWithoutAttendee`), so no host calendar holds them as
a guest it could re-invite. Reschedule, cancel, reassign and the reconciler read the mode from
the **booking**, not the event type, so a booking is always updated through the channel
its invite went out on. Switching an event type to `calnode` requires email to be set up
(validated on change).

**Adding another provider (Apple/iCloud, CalDAV):** implement `calendar.Provider`,
register it in `internal/server`, set `InvitesGuests()` correctly (drives the `.ics`
gate above), and extend `providerMintsPlatform`/`CanAutoGenerate` if it offers a
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export type EventType = {
* them. Off by default: the slots endpoint is public, so this makes the host's
* booked hours visible to anyone with the link. */
show_taken_slots: boolean;
/** Who sends the booker's calendar invite. 'calendar': each host's connected calendar,
* from the host's own address. 'calnode': Calnode's own invite, from the email sender
* in Settings → Email, so no host's personal address reaches the booker. */
invite_delivery: 'calendar' | 'calnode';
/** Whether Calnode can currently send invites (a sender and working email). Only
* returned on the single event type GET. */
invite_sender_ready?: boolean;
location_type: string;
location_value?: string;
buffer_before_minutes: number;
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/routes/event-types/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
phone: '+1 555 123 4567 (required)', in_person: 'Address (optional)',
};

const INVITE_DELIVERY = [
{ value: 'calendar', label: "The host's calendar" },
{ value: 'calnode', label: 'Calnode, from your workspace' },
];

// ── Event type ───────────────────────────────────────────────────────────────
let et = $state<EventType | null>(null);
const TABS = [
Expand All @@ -65,6 +70,7 @@
let form = $state({
name: '', slug: '', description: '', duration_minutes: 30, slot_interval_minutes: 30,
is_active: true, is_public: true, show_taken_slots: false,
invite_delivery: 'calendar' as EventType['invite_delivery'],
location_type: 'link', location_value: '',
buffer_before_minutes: 0, buffer_after_minutes: 0,
min_notice_minutes: 0, max_future_days: 60,
Expand Down Expand Up @@ -285,6 +291,7 @@
is_active: et.is_active,
is_public: et.is_public,
show_taken_slots: et.show_taken_slots ?? false,
invite_delivery: et.invite_delivery ?? 'calendar',
location_type: et.location_type,
location_value: et.location_value ?? '',
buffer_before_minutes: et.buffer_before_minutes,
Expand Down Expand Up @@ -343,6 +350,7 @@
is_active: form.is_active,
is_public: form.is_public,
show_taken_slots: form.show_taken_slots,
invite_delivery: form.invite_delivery,
allow_phone_call: allowPhoneCall,
location_type: form.location_type,
location_value: form.location_value.trim() || null,
Expand Down Expand Up @@ -707,6 +715,42 @@
</div>
</div>

<!-- Calendar invites -->
<div class="mt-6 border-t pt-5">
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Calendar invite</p>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="et-invite">Sent by</Label>
<Select.Root type="single" bind:value={form.invite_delivery}>
<Select.Trigger id="et-invite" class="w-full">
{INVITE_DELIVERY.find((o) => o.value === form.invite_delivery)?.label ?? 'Select…'}
</Select.Trigger>
<Select.Content>
{#each INVITE_DELIVERY as o}
<Select.Item value={o.value} label={o.label}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="self-end text-xs text-muted-foreground">
{#if form.invite_delivery === 'calnode'}
Invitees get the invite from your workspace's email sender
(<a href="{base}/settings/email" class="underline">Settings → Email</a>), never from a host's
own address. Hosts still see the meeting on their own calendar, with the meeting link.
{:else}
The host's connected calendar invites the booker, so the invite shows the host's own
email address. Choose Calnode to keep hosts' addresses private or to book as a team.
{/if}
</p>
</div>
{#if form.invite_delivery === 'calnode' && et?.invite_sender_ready === false}
<p class="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Email isn't set up, so bookers won't receive an invite. Add a sender in
<a href="{base}/settings/email" class="underline">Settings → Email</a>.
</p>
{/if}
</div>

<!-- Price -->
<div class="mt-6 border-t pt-5">
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Price</p>
Expand Down
20 changes: 20 additions & 0 deletions internal/booking/booking.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ var (
ErrEmailThrottled = errors.New("booking: too many bookings from this email address")
)

// Who sends a booking's calendar invite to the booker (event_types/bookings.invite_delivery,
// migration 00068).
const (
// InviteByCalendar: the booker is a guest on each host's calendar event, and the host's
// provider (Google, Microsoft) emails the invite from the host's own account.
InviteByCalendar = "calendar"
// InviteByCalnode: the hosts' events are created without guests, and Calnode sends the
// invite itself, organized by the instance's sender identity instead of any one host.
InviteByCalnode = "calnode"
)

// Booking is a confirmed or cancelled appointment.
type Booking struct {
ID string
Expand All @@ -33,6 +44,9 @@ type Booking struct {
// ConfirmFailed reports whether the initial confirmation email failed (after
// retry). Operator-visible via the booking JSON; see migration 00064.
ConfirmFailed bool
// InviteDelivery is who sent this booking's invite (InviteByCalendar/InviteByCalnode),
// fixed at creation; reschedule, cancel and reassign follow it.
InviteDelivery string
}

// Attendee is a participant in a booking (the person who made the booking).
Expand Down Expand Up @@ -88,4 +102,10 @@ type CreateParams struct {
// creation transaction, next to the active cap, so concurrent submissions
// can't both slip past a read-then-write check.
MaxBookingsPerHour int
// InviteDelivery records who sends the booker's calendar invite for this booking:
// InviteByCalendar (the hosts' connected calendars; the default when empty) or
// InviteByCalnode (Calnode's own .ics, with the hosts' events created guestless).
// Stored on the booking so its reschedule and cancel follow the mode it was created
// with, not whatever the event type is set to by then.
InviteDelivery string
}
37 changes: 22 additions & 15 deletions internal/booking/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,18 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error)
}

bookingID := uid.New()
inviteDelivery := p.InviteDelivery
if inviteDelivery == "" {
inviteDelivery = InviteByCalendar
}

_, err = tx.ExecContext(ctx, `
INSERT INTO bookings
(id, event_type_id, host_id, start_at, end_at, status, location_value, location_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'confirmed', ?, ?, ?, ?)`,
bookingID, p.EventTypeID, chosenHost, startStr, endStr, p.LocationValue, p.LocationType, now, now)
(id, event_type_id, host_id, start_at, end_at, status, location_value, location_type,
invite_delivery, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'confirmed', ?, ?, ?, ?, ?)`,
bookingID, p.EventTypeID, chosenHost, startStr, endStr, p.LocationValue, p.LocationType,
inviteDelivery, now, now)
if err != nil {
if db.IsUniqueViolation(err) {
return nil, ErrDoubleBooked
Expand Down Expand Up @@ -211,16 +217,17 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error)

nowT, _ := time.Parse(time.RFC3339Nano, now)
return &Booking{
ID: bookingID,
EventTypeID: p.EventTypeID,
HostID: chosenHost,
StartAt: p.StartAt.UTC(),
EndAt: p.EndAt.UTC(),
Status: "confirmed",
LocationValue: p.LocationValue,
LocationType: p.LocationType,
CreatedAt: nowT,
UpdatedAt: nowT,
ID: bookingID,
EventTypeID: p.EventTypeID,
HostID: chosenHost,
StartAt: p.StartAt.UTC(),
EndAt: p.EndAt.UTC(),
Status: "confirmed",
LocationValue: p.LocationValue,
LocationType: p.LocationType,
InviteDelivery: inviteDelivery,
CreatedAt: nowT,
UpdatedAt: nowT,
}, nil
}

Expand Down Expand Up @@ -300,7 +307,7 @@ const bookingColumns = `id, event_type_id, host_id, start_at, end_at, status,
COALESCE(cancellation_reason, ''), COALESCE(location_value, ''),
created_at, updated_at,
payment_status, amount_paid_cents, amount_paid_currency, location_type,
confirm_failed`
confirm_failed, invite_delivery`

// hostBusy reports whether hostID has any non-cancelled booking overlapping
// [start, end) — the double-booking invariant every write path (Create, Reschedule,
Expand Down Expand Up @@ -669,7 +676,7 @@ func scanBooking(s scanner) (*Booking, error) {
&b.CancellationReason, &b.LocationValue,
&createdStr, &updatedStr,
&b.PaymentStatus, &b.AmountPaidCents, &b.AmountPaidCurrency, &b.LocationType,
&confirmFailed,
&confirmFailed, &b.InviteDelivery,
)
if err == sql.ErrNoRows {
return nil, ErrNotFound
Expand Down
25 changes: 25 additions & 0 deletions internal/db/migrations/00068_invite_delivery.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- +goose Up
-- Who sends the booker's calendar invite.
--
-- 'calendar' (default, today's behaviour): the event is created on each host's connected
-- calendar with the booker as a guest, and Google/Microsoft email the invite. The invite
-- comes from the host's own account, so the booker sees the host's personal address.
--
-- 'calnode': the host events are still created (they block time and mint the Meet/Teams
-- link) but WITHOUT the booker as a guest, so the provider emails nobody. Calnode sends
-- the invite itself as an .ics on the confirmation email, organized by the instance's
-- sender identity (Settings -> Email). For teams booking under one name, or hosts who
-- do not want their personal address on every invite.
--
-- Stored on the booking as well as the event type: the mode a booking was CREATED with
-- decides how its reschedule and cancel are delivered. Reading the event type's current
-- mode instead would, after the setting changes, cancel a Google-invited booking with an
-- .ics nobody has (or leave a Calnode-invited one with no cancellation at all).
ALTER TABLE event_types ADD COLUMN invite_delivery TEXT NOT NULL DEFAULT 'calendar'
CHECK (invite_delivery IN ('calendar', 'calnode'));
ALTER TABLE bookings ADD COLUMN invite_delivery TEXT NOT NULL DEFAULT 'calendar'
CHECK (invite_delivery IN ('calendar', 'calnode'));

-- +goose Down
ALTER TABLE bookings DROP COLUMN invite_delivery;
ALTER TABLE event_types DROP COLUMN invite_delivery;
28 changes: 17 additions & 11 deletions internal/handler/booking_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,9 @@ type bookableEventType struct {
// so the page can grey them out. Read by the public slots endpoint only: the MCP
// tool and the booking assistant must keep seeing bookable times and nothing else.
ShowTakenSlots bool
// InviteDelivery is who sends the booker's invite (booking.InviteByCalendar/ByCalnode),
// copied onto each booking at creation.
InviteDelivery string
}

// loadBookableEventType loads an event type by slug for the booking-creation/slot paths.
Expand All @@ -486,12 +489,14 @@ func (h *Handler) loadBookableEventType(ctx context.Context, slug string) (*book
SELECT id, user_id, name, duration_minutes, slot_interval_minutes,
location_type, location_value, allow_phone_call, routing_mode, rr_strategy,
buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_future_days,
is_active, is_public, show_taken_slots, max_active_bookings, price_cents, currency
is_active, is_public, show_taken_slots, max_active_bookings, price_cents, currency,
invite_delivery
FROM event_types WHERE slug = ?`, slug).
Scan(&et.ID, &et.UserID, &et.Name, &et.DurationMinutes, &et.SlotIntervalMinutes,
&et.LocationType, &et.LocationValue, &et.AllowPhoneCall, &et.RoutingMode, &et.RRStrategy,
&et.BufferBeforeMinutes, &et.BufferAfterMinutes, &et.MinNoticeMinutes, &et.MaxFutureDays,
&isActive, &isPublic, &showTaken, &et.MaxActiveBookings, &et.PriceCents, &et.Currency)
&isActive, &isPublic, &showTaken, &et.MaxActiveBookings, &et.PriceCents, &et.Currency,
&et.InviteDelivery)
if err != nil || isActive == 0 || isPublic == 0 {
return nil, errEventTypeNotFound
}
Expand Down Expand Up @@ -570,6 +575,7 @@ func (h *Handler) createBookingForSlug(ctx context.Context, slug string, startAt
Answers: answers,
MaxActivePerInvitee: et.MaxActiveBookings,
MaxBookingsPerHour: maxBookingsPerEmailPerHour,
InviteDelivery: et.InviteDelivery,
})
if err != nil {
return nil, err
Expand Down Expand Up @@ -900,6 +906,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
Answers: answers,
MaxActivePerInvitee: et.MaxActiveBookings,
MaxBookingsPerHour: maxBookingsPerEmailPerHour,
InviteDelivery: et.InviteDelivery,
})
if err != nil {
if errors.Is(err, booking.ErrDoubleBooked) {
Expand Down Expand Up @@ -1030,12 +1037,11 @@ func (h *Handler) hostPrefsOrDefault(ctx context.Context, bookingID, userID stri

// hostBookingData returns a per-host copy of base with the fields every host
// notification email needs filled in: the host's own name/email (so "with <name>"
// reads correctly), whether to attach an ICS (only when that host has no connected
// destination calendar of their own), and the ICS sequence number.
func (h *Handler) hostBookingData(ctx context.Context, base mailer.BookingData, host assignedHost, updatedAt time.Time) mailer.BookingData {
// reads correctly), the host's own invite (applyHostInvite), and the ICS sequence number.
func (h *Handler) hostBookingData(ctx context.Context, base mailer.BookingData, host assignedHost, inviteMode string, updatedAt time.Time) mailer.BookingData {
hd := base
hd.HostName, hd.HostEmail = host.Name, host.Email
hd.AttachICS = h.noConnectedDestination(ctx, host.UserID)
h.applyHostInvite(ctx, &hd, inviteMode, host.UserID)
hd.ICSSequence = int(updatedAt.Unix())
return hd
}
Expand Down Expand Up @@ -1192,7 +1198,7 @@ func (h *Handler) createHostEventsAndNotify(ctx context.Context, b *booking.Book
Start: b.StartAt,
End: b.EndAt,
OrganizerName: in.OrganizerName,
OrganizerEmail: in.OrganizerEmail,
OrganizerEmail: calendarInvitee(b.InviteDelivery, in.OrganizerEmail),
AddMeet: autoGenMeet && host.IsPrimary,
})
if err != nil {
Expand Down Expand Up @@ -1234,7 +1240,7 @@ func (h *Handler) createHostEventsAndNotify(ctx context.Context, b *booking.Book
primaryPrefs = prefs
}
if prefs.NotifyHostBooking {
hd := h.hostBookingData(ctx, *bData, host, b.UpdatedAt)
hd := h.hostBookingData(ctx, *bData, host, b.InviteDelivery, b.UpdatedAt)
if livekitHostURL != "" {
hd.LocationValue = livekitHostURL // host email gets the controls-enabled link
}
Expand Down Expand Up @@ -1325,7 +1331,7 @@ func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConf
// Attendee confirmation, once. "With:" names the primary host; gated on the
// primary host's notification preference (matches prior behaviour).
bData.HostName, bData.HostEmail = primaryHost(hosts).Name, primaryHost(hosts).Email
bData.AttachICS = h.noConnectedDestination(ctx, b.HostID)
h.applyInviteDelivery(ctx, &bData, b.InviteDelivery, b.HostID)
bData.ICSSequence = int(b.UpdatedAt.Unix())
confirmFailed := hostFailed
if primaryPrefs.NotifyConfirmation {
Expand Down Expand Up @@ -1764,13 +1770,13 @@ func (h *Handler) cancelSideEffects(b booking.Booking) {
d.HostName, d.HostEmail = host.Name, host.Email // attendee "With:" = primary host, not owner
}
if prefs.NotifyHostCancel {
hd := h.hostBookingData(ctx, d, host, b.UpdatedAt)
hd := h.hostBookingData(ctx, d, host, b.InviteDelivery, b.UpdatedAt)
if err := mailer.SendCancellationToHost(ctx, h.mailer, hd); err != nil {
h.logger.Error("booking cancellation email (host)", "error", err, "booking_id", b.ID, "host", host.UserID)
}
}
}
d.AttachICS = h.noConnectedDestination(ctx, b.HostID)
h.applyInviteDelivery(ctx, &d, b.InviteDelivery, b.HostID)
d.ICSSequence = int(b.UpdatedAt.Unix())
if primaryPrefs.NotifyCancellation {
if err := mailer.SendCancellationToAttendee(ctx, h.mailer, d); err != nil {
Expand Down
Loading
Loading