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
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ them - the most common "why can't I see those times".

## 9. Booking lifecycle

Google Meet and Teams event types can opt into `allow_phone_call`. Their booking page and widget then accept an optional `phone` value. A valid number selects a telephone appointment; leaving it empty preserves video. The booking stores its own location type so management pages, calendar retries, and paid confirmation do not generate a video link for a telephone appointment. Event duplication preserves the setting.

`internal/booking/service.go` (transactions) + `internal/handler/booking_handler.go`
(HTTP + async side effects).

Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type User = {
};

export type EventType = {
allow_phone_call: boolean;
id: string;
slug: string;
name: string;
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/routes/event-types/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,14 @@
let testSent = $state<Partial<Record<MsgKey, boolean>>>({});
let testError = $state<Partial<Record<MsgKey, string>>>({});

let allowPhoneCall = $state(false);
const slug = $page.params.slug;

async function loadET() {
etError = '';
try {
et = await api.get<EventType>(`/v1/event-types/${slug}`);
allowPhoneCall = et.allow_phone_call;
form = {
name: et.name,
slug: et.slug,
Expand Down Expand Up @@ -323,6 +325,7 @@
is_active: form.is_active,
is_public: form.is_public,
show_taken_slots: form.show_taken_slots,
allow_phone_call: allowPhoneCall,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow_phone_call is sent as the raw state value, so it stays true in the DB when the operator switches the location away from an online meeting — the checkbox is hidden but allowPhoneCall is never cleared, and the flag silently reappears if the type is switched back. All read paths gate on onlineMeetingLocation, so this isn't exploitable today, but sending allowPhoneCall && isOnlineMeeting(form.location_type) (and resetting the state when location_type changes) keeps the stored value honest.

location_type: form.location_type,
location_value: form.location_value.trim() || null,
buffer_before_minutes: Number(form.buffer_before_minutes),
Expand Down Expand Up @@ -620,6 +623,9 @@
</p>
</div>

{#if isOnlineMeeting(form.location_type)}
<label class="flex items-center gap-3 text-sm"><input type="checkbox" bind:checked={allowPhoneCall} />Let invitees choose a phone call by entering their number</label>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest of this form uses the shadcn Checkbox + Label pair (see show_taken_slots just above at lines 611-612), whereas this is a raw native <input type="checkbox"> — it renders with browser-default styling and misses the shared focus/disabled states. It also sits above the Location section header even though the option only applies when the location is an online meeting; consider rendering the shadcn pair next to the location select instead.

{/if}
<!-- Location -->
<div class="mt-6 border-t pt-5">
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Location</p>
Expand Down
2 changes: 2 additions & 0 deletions internal/booking/booking.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Booking struct {
Status string
CancellationReason string
LocationValue string
LocationType string
CreatedAt time.Time
UpdatedAt time.Time
PaymentStatus string // none | pending | paid | refunded
Expand Down Expand Up @@ -71,6 +72,7 @@ type CreateParams struct {
StartAt time.Time
EndAt time.Time
LocationValue string
LocationType string
Organizer Attendee
Answers []Answer
// MaxActivePerInvitee caps how many active (upcoming, non-cancelled) bookings
Expand Down
11 changes: 6 additions & 5 deletions internal/booking/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error)

_, err = tx.ExecContext(ctx, `
INSERT INTO bookings
(id, event_type_id, host_id, start_at, end_at, status, location_value, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'confirmed', ?, ?, ?)`,
bookingID, p.EventTypeID, chosenHost, startStr, endStr, p.LocationValue, now, now)
(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)
if err != nil {
if db.IsUniqueViolation(err) {
return nil, ErrDoubleBooked
Expand Down Expand Up @@ -201,6 +201,7 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error)
EndAt: p.EndAt.UTC(),
Status: "confirmed",
LocationValue: p.LocationValue,
LocationType: p.LocationType,
CreatedAt: nowT,
UpdatedAt: nowT,
}, nil
Expand Down Expand Up @@ -271,7 +272,7 @@ func (s *Service) CancelByID(ctx context.Context, id, reason string) error {
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`
payment_status, amount_paid_cents, amount_paid_currency, location_type`

// 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 @@ -628,7 +629,7 @@ func scanBooking(s scanner) (*Booking, error) {
&startStr, &endStr, &b.Status,
&b.CancellationReason, &b.LocationValue,
&createdStr, &updatedStr,
&b.PaymentStatus, &b.AmountPaidCents, &b.AmountPaidCurrency,
&b.PaymentStatus, &b.AmountPaidCents, &b.AmountPaidCurrency, &b.LocationType,
)
if err == sql.ErrNoRows {
return nil, ErrNotFound
Expand Down
7 changes: 7 additions & 0 deletions internal/db/migrations/00061_telephone_booking_choice.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE event_types ADD COLUMN allow_phone_call INTEGER NOT NULL DEFAULT 0;
ALTER TABLE bookings ADD COLUMN location_type TEXT NOT NULL DEFAULT '';

-- +goose Down
ALTER TABLE bookings DROP COLUMN location_type;
ALTER TABLE event_types DROP COLUMN allow_phone_call;
49 changes: 27 additions & 22 deletions internal/handler/book.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type bookQuestion struct {
}

type bookPageData struct {
PhoneChoice bool
Slug string
Name string
Description template.HTML
Expand Down Expand Up @@ -319,16 +320,17 @@ func (h *Handler) PublicEventType(w http.ResponseWriter, r *http.Request) {
hostName, avatarURL, routingMode, currency string
durMins, maxDays, minNotice, priceCents int
msgGreeting sql.NullString
allowPhoneCall bool
)
err := h.db.QueryRowContext(r.Context(), `
SELECT et.id, et.name, COALESCE(et.description, ''),
et.duration_minutes, et.location_type, COALESCE(et.location_value, ''),
et.max_future_days, et.min_notice_minutes, et.routing_mode, u.name, COALESCE(u.avatar_url, ''),
et.price_cents, et.currency, et.msg_greeting
et.price_cents, et.currency, et.msg_greeting, et.allow_phone_call
FROM event_types et
JOIN users u ON u.id = et.user_id
WHERE et.slug = ? AND et.is_active = 1 AND et.is_public = 1`,
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting)
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting, &allowPhoneCall)
if errors.Is(err, sql.ErrNoRows) {
h.writeError(w, http.StatusNotFound, "event type not found")
return
Expand Down Expand Up @@ -387,10 +389,11 @@ func (h *Handler) PublicEventType(w http.ResponseWriter, r *http.Request) {
// doesn't have to rebuild it from duration_minutes (it used to hardcode " min",
// which both skipped translation and disagreed with the pages for >= 60 min).
// duration_minutes stays for clients that want the raw number.
"duration_label": durationLabel(durMins, loc),
"location_type": locType,
"location_label": locationLabel(locType, locValue, loc),
"max_future_days": maxDays,
"duration_label": durationLabel(durMins, loc),
"location_type": locType,
"allow_phone_call": allowPhoneCall && onlineMeetingLocation(locType),
"location_label": locationLabel(locType, locValue, loc),
"max_future_days": maxDays,
// min_notice_label is the translated minimum-notice duration ("4 hours"), empty
// when the event type sets none. The widget needs it here because the /slots call
// it makes later carries no language of its own, and min_notice_minutes alone
Expand All @@ -415,30 +418,31 @@ func (h *Handler) BookPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")

var (
etID string
name string
description string
durMins int
locType string
locValue string
maxDays int
minNotice int
hostName string
avatarURL string
routingMode string
priceCents int
currency string
msgGreeting sql.NullString
etID string
name string
description string
durMins int
locType string
locValue string
maxDays int
minNotice int
hostName string
avatarURL string
routingMode string
priceCents int
currency string
msgGreeting sql.NullString
allowPhoneCall bool
)
err := h.db.QueryRowContext(r.Context(), `
SELECT et.id, et.name, COALESCE(et.description, ''),
et.duration_minutes, et.location_type, COALESCE(et.location_value, ''),
et.max_future_days, et.min_notice_minutes, et.routing_mode, u.name, COALESCE(u.avatar_url, ''),
et.price_cents, et.currency, et.msg_greeting
et.price_cents, et.currency, et.msg_greeting, et.allow_phone_call
FROM event_types et
JOIN users u ON u.id = et.user_id
WHERE et.slug = ? AND et.is_active = 1 AND et.is_public = 1`,
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting)
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting, &allowPhoneCall)

if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Page not found", http.StatusNotFound)
Expand Down Expand Up @@ -500,6 +504,7 @@ func (h *Handler) BookPage(w http.ResponseWriter, r *http.Request) {
i18nJSON, _ := loc.JSON()

data := bookPageData{
PhoneChoice: allowPhoneCall && onlineMeetingLocation(locType),
Slug: slug,
Name: name,
Description: renderMarkdown(description),
Expand Down
22 changes: 20 additions & 2 deletions internal/handler/booking_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ var errPaymentRequired = errors.New("this event requires payment; please book on
// the direct booking page (book.go) always had. A future caller now gets both is_active
// and is_public enforced by construction, not by remembering to add the check.
type bookableEventType struct {
AllowPhoneCall bool
ID string
UserID string
Name string
Expand Down Expand Up @@ -456,12 +457,12 @@ func (h *Handler) loadBookableEventType(ctx context.Context, slug string) (*book
var isActive, isPublic, showTaken int
err := h.db.QueryRowContext(ctx, `
SELECT id, user_id, name, duration_minutes, slot_interval_minutes,
location_type, location_value, routing_mode, rr_strategy,
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
FROM event_types WHERE slug = ?`, slug).
Scan(&et.ID, &et.UserID, &et.Name, &et.DurationMinutes, &et.SlotIntervalMinutes,
&et.LocationType, &et.LocationValue, &et.RoutingMode, &et.RRStrategy,
&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)
if err != nil || isActive == 0 || isPublic == 0 {
Expand Down Expand Up @@ -520,6 +521,7 @@ func (h *Handler) createBookingForSlug(ctx context.Context, slug string, startAt
StartAt: startAt.UTC(),
EndAt: endAt,
LocationValue: locValue,
LocationType: et.LocationType,
Organizer: organizer,
Answers: answers,
MaxActivePerInvitee: et.MaxActiveBookings,
Expand Down Expand Up @@ -555,6 +557,7 @@ type bookingJSON struct {
Status string `json:"status"`
CancellationReason string `json:"cancellation_reason,omitempty"`
LocationValue string `json:"location_value,omitempty"`
LocationType string `json:"location_type,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
PaymentStatus string `json:"payment_status,omitempty" jsonschema:"payment state for paid event types: paid, refunded, or pending; absent for free bookings"`
Expand All @@ -581,6 +584,7 @@ func toBookingJSON(b *booking.Booking) bookingJSON {
Status: b.Status,
CancellationReason: b.CancellationReason,
LocationValue: b.LocationValue,
LocationType: b.LocationType,
CreatedAt: b.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: b.UpdatedAt.UTC().Format(time.RFC3339),
}
Expand Down Expand Up @@ -642,6 +646,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
StartAt string `json:"start_at"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Timezone string `json:"timezone"`
// Language is the resolved page locale code (e.g. "es") the client already knows —
// sent explicitly rather than re-derived from Accept-Language/cookie server-side,
Expand Down Expand Up @@ -776,6 +781,15 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {

// Resolve candidate hosts by routing mode: rotation hosts for round-robin,
// required hosts otherwise. Archived hosts are excluded by resolveEventTypeHosts.
req.Phone = strings.TrimSpace(req.Phone)
if req.Phone != "" {
if !et.AllowPhoneCall || !onlineMeetingLocation(et.LocationType) || len(req.Phone) > 40 || !validPhone(req.Phone) {
h.writeError(w, http.StatusBadRequest, "invalid telephone number")
return
}
et.LocationType = "phone"
locValue = "tel:" + req.Phone
}
hosts, err := h.resolveEventTypeHosts(r.Context(), et.ID)
if err != nil {
h.logger.ErrorContext(r.Context(), "create booking: resolve hosts", "error", err)
Expand Down Expand Up @@ -803,6 +817,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
StartAt: startAt.UTC(),
EndAt: endAt,
LocationValue: locValue,
LocationType: et.LocationType,
Organizer: booking.Attendee{
Name: req.Name,
Email: req.Email,
Expand Down Expand Up @@ -958,6 +973,9 @@ func (h *Handler) hostBookingData(ctx context.Context, base mailer.BookingData,
// createHostEventsAndNotify), since only the calendar API call itself produces it.
func (h *Handler) mintMeetingLink(ctx context.Context, b *booking.Booking, in bookingConfirmationInput, bData *mailer.BookingData, hosts []assignedHost) (meetURL string, autoGenMeet bool, livekitHostURL string) {
gc := h.getCal()
if in.LocationType == "phone" {
meetURL = b.LocationValue
}
if gc != nil && onlineMeetingLocation(in.LocationType) {
if _, primaryProvider, perr := gc.Connected(ctx, primaryHost(hosts).UserID); perr == nil {
autoGenMeet = providerMintsPlatform(in.LocationType, primaryProvider)
Expand Down
Loading
Loading