From 16f49d6538441e2f736dbe3b12f8869598273929 Mon Sep 17 00:00:00 2001 From: Marijn Bent Date: Sun, 20 Sep 2026 20:44:12 +0200 Subject: [PATCH] feat(booking): offer an optional phone call for online events --- docs/ARCHITECTURE.md | 2 + frontend/src/lib/api.ts | 1 + .../routes/event-types/[slug]/+page.svelte | 6 + internal/booking/booking.go | 2 + internal/booking/service.go | 11 +- .../00061_telephone_booking_choice.sql | 7 ++ internal/handler/book.go | 49 +++++---- internal/handler/booking_handler.go | 22 +++- internal/handler/booking_phone_test.go | 104 ++++++++++++++++++ internal/handler/calendar_reconcile.go | 2 +- internal/handler/embed.js | 4 +- internal/handler/event_type.go | 16 ++- internal/handler/event_type_duplicate.go | 4 +- internal/handler/event_type_duplicate_test.go | 2 +- internal/handler/manage_handler.go | 7 ++ internal/handler/stripe_booking.go | 2 +- internal/handler/templates/book.html | 8 ++ internal/i18n/locales/de.json | 3 +- internal/i18n/locales/en.json | 3 +- internal/i18n/locales/es.json | 3 +- internal/i18n/locales/fr.json | 3 +- internal/i18n/locales/it.json | 3 +- internal/i18n/locales/nl.json | 3 +- internal/i18n/locales/pt.json | 3 +- internal/i18n/locales/sv.json | 3 +- 25 files changed, 225 insertions(+), 48 deletions(-) create mode 100644 internal/db/migrations/00061_telephone_booking_choice.sql create mode 100644 internal/handler/booking_phone_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58defaab..9b385c7a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 89b63a16..b614285d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,6 +20,7 @@ export type User = { }; export type EventType = { + allow_phone_call: boolean; id: string; slug: string; name: string; diff --git a/frontend/src/routes/event-types/[slug]/+page.svelte b/frontend/src/routes/event-types/[slug]/+page.svelte index e905e2ac..2763ae1a 100644 --- a/frontend/src/routes/event-types/[slug]/+page.svelte +++ b/frontend/src/routes/event-types/[slug]/+page.svelte @@ -250,12 +250,14 @@ let testSent = $state>>({}); let testError = $state>>({}); + let allowPhoneCall = $state(false); const slug = $page.params.slug; async function loadET() { etError = ''; try { et = await api.get(`/v1/event-types/${slug}`); + allowPhoneCall = et.allow_phone_call; form = { name: et.name, slug: et.slug, @@ -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, location_type: form.location_type, location_value: form.location_value.trim() || null, buffer_before_minutes: Number(form.buffer_before_minutes), @@ -620,6 +623,9 @@

+ {#if isOnlineMeeting(form.location_type)} + + {/if}

Location

diff --git a/internal/booking/booking.go b/internal/booking/booking.go index 84131af4..1ac2aa45 100644 --- a/internal/booking/booking.go +++ b/internal/booking/booking.go @@ -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 @@ -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 diff --git a/internal/booking/service.go b/internal/booking/service.go index 665dc1f5..56ba6187 100644 --- a/internal/booking/service.go +++ b/internal/booking/service.go @@ -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 @@ -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 @@ -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, @@ -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 diff --git a/internal/db/migrations/00061_telephone_booking_choice.sql b/internal/db/migrations/00061_telephone_booking_choice.sql new file mode 100644 index 00000000..4fc2dc77 --- /dev/null +++ b/internal/db/migrations/00061_telephone_booking_choice.sql @@ -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; diff --git a/internal/handler/book.go b/internal/handler/book.go index cb5ef2b1..aa7802f8 100644 --- a/internal/handler/book.go +++ b/internal/handler/book.go @@ -44,6 +44,7 @@ type bookQuestion struct { } type bookPageData struct { + PhoneChoice bool Slug string Name string Description template.HTML @@ -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, ¤cy, &msgGreeting) + slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting, &allowPhoneCall) if errors.Is(err, sql.ErrNoRows) { h.writeError(w, http.StatusNotFound, "event type not found") return @@ -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 @@ -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, ¤cy, &msgGreeting) + slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting, &allowPhoneCall) if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Page not found", http.StatusNotFound) @@ -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), diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index c695305e..478046b7 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -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 @@ -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 { @@ -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, @@ -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"` @@ -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), } @@ -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, @@ -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) @@ -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, @@ -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) diff --git a/internal/handler/booking_phone_test.go b/internal/handler/booking_phone_test.go new file mode 100644 index 00000000..f6aa11d6 --- /dev/null +++ b/internal/handler/booking_phone_test.go @@ -0,0 +1,104 @@ +package handler_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/slots" +) + +type telephoneCalendar struct { + calendar.Provider + events chan calendar.CreateEventParams +} + +func (p telephoneCalendar) Name() string { return "google" } +func (p telephoneCalendar) InvitesGuests() bool { return true } +func (p telephoneCalendar) HasDestination(context.Context, string) (bool, error) { return true, nil } +func (p telephoneCalendar) FreeBusy(context.Context, string, time.Time, time.Time) ([]slots.Interval, error) { + return nil, nil +} +func (p telephoneCalendar) CreateEvent(_ context.Context, _ string, in calendar.CreateEventParams) (string, string, string, error) { + p.events <- in + return "event-id", "", "primary", nil +} + +func TestBookingTelephoneChoice(t *testing.T) { + for _, tc := range []struct { + name, phone, locationType, location string + status int + meet bool + }{ + {"telephone", "+31 6 12345678", "phone", "tel:+31 6 12345678", 201, false}, + {"meet", "", "google_meet", "", 201, true}, + {"invalid", "call me", "", "", 400, false}, + {"disabled", "+31 6 12345678", "", "", 400, false}, + } { + t.Run(tc.name, func(t *testing.T) { + h, db, key, userID := setupWorkspaceWithDB(t) + _, eventID := seedEventTypeHTTP(t, h, key) + if _, err := db.Exec(`UPDATE event_types SET slug = 'studiozoek-kennismaking', location_type = 'google_meet', allow_phone_call = 1 WHERE id = ?`, eventID); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO calendar_connections (id,user_id,provider,access_token_enc,calendar_id,is_destination) VALUES ('conn',?,'google','test','primary',1)`, userID); err != nil { + t.Fatal(err) + } + if tc.name == "disabled" { + if _, err := db.Exec(`UPDATE event_types SET allow_phone_call = 0 WHERE id = ?`, eventID); err != nil { + t.Fatal(err) + } + } + p := telephoneCalendar{events: make(chan calendar.CreateEventParams, 1)} + svc := calendar.NewService(db) + svc.Register(p) + h.SetCalendar(svc) + body := fmt.Sprintf(`{"event_type_slug":"studiozoek-kennismaking","start_at":"2026-06-15T09:00:00Z","name":"Test","email":"test@example.com","phone":%q}`, tc.phone) + req := httptest.NewRequest(http.MethodPost, "/v1/bookings", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.CreateBooking(rec, req) + if rec.Code != tc.status { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + if tc.status != 201 { + var count int + db.QueryRow(`SELECT COUNT(*) FROM bookings`).Scan(&count) + if count != 0 { + t.Fatal("invalid number created a booking") + } + return + } + var response struct { + ID string `json:"id"` + LocationType string `json:"location_type"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.LocationType != tc.locationType { + t.Fatalf("response location type %q", response.LocationType) + } + var locType, loc string + if err := db.QueryRow(`SELECT location_type, location_value FROM bookings WHERE id = ?`, response.ID).Scan(&locType, &loc); err != nil { + t.Fatal(err) + } + if locType != tc.locationType || loc != tc.location { + t.Fatalf("stored location %q %q", locType, loc) + } + select { + case event := <-p.events: + if event.AddMeet != tc.meet || event.Location != tc.location { + t.Fatalf("calendar AddMeet=%v location=%q", event.AddMeet, event.Location) + } + case <-time.After(3 * time.Second): + t.Fatal("calendar event was not created") + } + }) + } +} diff --git a/internal/handler/calendar_reconcile.go b/internal/handler/calendar_reconcile.go index 5ff52c11..c215722d 100644 --- a/internal/handler/calendar_reconcile.go +++ b/internal/handler/calendar_reconcile.go @@ -160,7 +160,7 @@ func (h *Handler) reconcileCreations(ctx context.Context, gc *calendar.Service) } var items []missing rows, err := h.db.QueryContext(ctx, ` - SELECT bh.booking_id, bh.user_id, bh.is_primary, et.name, et.location_type, + SELECT bh.booking_id, bh.user_id, bh.is_primary, et.name, COALESCE(NULLIF(b.location_type, ''), et.location_type), COALESCE(b.location_value, ''), COALESCE(o.name, ''), COALESCE(o.email, ''), COALESCE(o.locale, ''), b.start_at, b.end_at FROM booking_hosts bh diff --git a/internal/handler/embed.js b/internal/handler/embed.js index f9f1c279..378213a8 100644 --- a/internal/handler/embed.js +++ b/internal/handler/embed.js @@ -561,6 +561,8 @@ var email = el('input', { type: 'email', required: 'required', autocomplete: 'email', placeholder: t(this.i18n, 'email_placeholder') }); form.appendChild(el('div', { class: 'field' }, [el('label', { text: t(this.i18n, 'name_label') }), name])); form.appendChild(el('div', { class: 'field' }, [el('label', { text: t(this.i18n, 'email_label') }), email])); + var phone = el('input', { type: 'tel', maxlength: '40', autocomplete: 'tel', id: 'phone-call-number' }); + if (this.info.allow_phone_call) form.appendChild(el('div', { class: 'field' }, [el('label', { for: 'phone-call-number', text: t(this.i18n, 'phone_call_number') }), phone])); var qInputs = []; this.questions.forEach(function (q) { var inp, field; @@ -603,7 +605,7 @@ }); fetch(BASE + '/v1/bookings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ event_type_slug: self.slug, start_at: slot.start, name: name.value.trim(), email: email.value.trim().toLowerCase(), timezone: TZ, language: self.locale, hp_extra: hp.value, answers: answers }), + body: JSON.stringify({ event_type_slug: self.slug, start_at: slot.start, name: name.value.trim(), email: email.value.trim().toLowerCase(), phone: phone.value.trim(), timezone: TZ, language: self.locale, hp_extra: hp.value, answers: answers }), }).then(function (r) { return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; }); }).then(function (res) { diff --git a/internal/handler/event_type.go b/internal/handler/event_type.go index 5c50f9cf..a49413a9 100644 --- a/internal/handler/event_type.go +++ b/internal/handler/event_type.go @@ -19,6 +19,7 @@ const ( ) type eventTypeJSON struct { + AllowPhoneCall bool `json:"allow_phone_call"` ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` @@ -87,7 +88,7 @@ func scanEventTypeRow(s rowScanner, trailing ...any) (*eventTypeJSON, error) { dests := []any{ &et.ID, &et.Slug, &et.Name, &desc, &et.DurationMinutes, &et.SlotIntervalMinutes, - &et.LocationType, &locVal, + &et.LocationType, &locVal, &et.AllowPhoneCall, &et.RoutingMode, &et.RRStrategy, &et.BufferBeforeMinutes, &et.BufferAfterMinutes, &et.MinNoticeMinutes, &et.MaxFutureDays, &et.MaxActiveBookings, @@ -146,7 +147,7 @@ func scanEventTypeRow(s rowScanner, trailing ...any) (*eventTypeJSON, error) { const etColumns = `id, slug, name, description, duration_minutes, slot_interval_minutes, - location_type, location_value, + location_type, location_value, allow_phone_call, routing_mode, rr_strategy, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_future_days, max_active_bookings, @@ -218,6 +219,7 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { MinNoticeMinutes *int `json:"min_notice_minutes"` MaxFutureDays *int `json:"max_future_days"` MaxActiveBookings *int `json:"max_active_bookings"` + AllowPhoneCall *bool `json:"allow_phone_call"` ShowTakenSlots *bool `json:"show_taken_slots"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -306,13 +308,13 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { _, err = tx.ExecContext(r.Context(), ` INSERT INTO event_types (id, user_id, slug, name, description, duration_minutes, - slot_interval_minutes, location_type, location_value, + slot_interval_minutes, location_type, location_value, allow_phone_call, routing_mode, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_future_days, max_active_bookings, show_taken_slots, msg_confirmation, msg_cancellation, msg_reschedule, msg_reminder) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, user.ID, req.Slug, req.Name, req.Description, - req.DurationMinutes, slotInterval, locType, req.LocationValue, + req.DurationMinutes, slotInterval, locType, req.LocationValue, req.AllowPhoneCall != nil && *req.AllowPhoneCall, routingMode, bufBefore, bufAfter, minNotice, maxFuture, maxActive, showTaken, defaultMsgConfirmation, defaultMsgCancellation, defaultMsgReschedule, defaultMsgReminder) if err != nil { @@ -444,6 +446,7 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { MaxActiveBookings *int `json:"max_active_bookings"` IsActive *bool `json:"is_active"` IsPublic *bool `json:"is_public"` + AllowPhoneCall *bool `json:"allow_phone_call"` ShowTakenSlots *bool `json:"show_taken_slots"` Archived *bool `json:"archived"` MsgConfirmation *string `json:"msg_confirmation"` @@ -589,6 +592,9 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } set("is_public", v) } + if req.AllowPhoneCall != nil { + set("allow_phone_call", *req.AllowPhoneCall) + } if req.ShowTakenSlots != nil { v := 0 if *req.ShowTakenSlots { diff --git a/internal/handler/event_type_duplicate.go b/internal/handler/event_type_duplicate.go index 3f76702b..e24ed8dd 100644 --- a/internal/handler/event_type_duplicate.go +++ b/internal/handler/event_type_duplicate.go @@ -134,7 +134,7 @@ func (h *Handler) DuplicateEventType(w http.ResponseWriter, r *http.Request) { INSERT INTO event_types ( id, user_id, team_id, slug, name, description, duration_minutes, slot_interval_minutes, - location_type, location_value, + location_type, location_value, allow_phone_call, routing_mode, rr_strategy, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_future_days, max_active_bookings, seat_limit, @@ -145,7 +145,7 @@ func (h *Handler) DuplicateEventType(w http.ResponseWriter, r *http.Request) { SELECT ?, user_id, team_id, ?, name, description, duration_minutes, slot_interval_minutes, - location_type, location_value, + location_type, location_value, allow_phone_call, routing_mode, rr_strategy, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_future_days, max_active_bookings, seat_limit, diff --git a/internal/handler/event_type_duplicate_test.go b/internal/handler/event_type_duplicate_test.go index c4485865..ecb7b465 100644 --- a/internal/handler/event_type_duplicate_test.go +++ b/internal/handler/event_type_duplicate_test.go @@ -593,7 +593,7 @@ func TestDuplicateEventType_handlesEveryEventTypeColumn(t *testing.T) { copied := map[string]bool{ "user_id": true, "team_id": true, "name": true, "description": true, "duration_minutes": true, "slot_interval_minutes": true, - "location_type": true, "location_value": true, + "location_type": true, "location_value": true, "allow_phone_call": true, "routing_mode": true, "rr_strategy": true, "buffer_before_minutes": true, "buffer_after_minutes": true, "min_notice_minutes": true, "max_future_days": true, diff --git a/internal/handler/manage_handler.go b/internal/handler/manage_handler.go index 412df064..ff660a3b 100644 --- a/internal/handler/manage_handler.go +++ b/internal/handler/manage_handler.go @@ -107,6 +107,13 @@ func (h *Handler) ManagePage(w http.ResponseWriter, r *http.Request) { return } + if b.LocationType != "" { + locType = b.LocationType + } + if b.LocationValue != "" { + locValue = b.LocationValue + } + // Show the actual assigned host(s) for this booking, not the event-type owner // (round-robin/Group route elsewhere). Falls back to the owner name above if // no booking_hosts rows exist. The avatar uses the primary host. diff --git a/internal/handler/stripe_booking.go b/internal/handler/stripe_booking.go index 06292f7e..764f2875 100644 --- a/internal/handler/stripe_booking.go +++ b/internal/handler/stripe_booking.go @@ -113,7 +113,7 @@ func (h *Handler) confirmPaidBooking(ctx context.Context, bookingID, paymentInte // Reconstruct the confirmation context from the booking's event type + organizer. var in bookingConfirmationInput if err := h.db.QueryRowContext(ctx, ` - SELECT et.name, et.slug, et.location_type, a.name, a.email, a.iana_timezone, a.locale + SELECT et.name, et.slug, COALESCE(NULLIF(b.location_type, ''), et.location_type), a.name, a.email, a.iana_timezone, a.locale FROM bookings b JOIN event_types et ON et.id = b.event_type_id JOIN booking_attendees a ON a.booking_id = b.id AND a.is_organizer = 1 diff --git a/internal/handler/templates/book.html b/internal/handler/templates/book.html index 4145bedb..49cecac2 100644 --- a/internal/handler/templates/book.html +++ b/internal/handler/templates/book.html @@ -209,6 +209,12 @@

{{.Name}}

.ID is the event type's own intake-question primary key (admin-configured, not attacker-controlled at booking time), and Go's html/template context-aware-escapes every {{.ID}} below regardless — there is no raw/unescaped output here. */}} + {{if .PhoneChoice}} +
+ + +
+ {{end}} {{range .Questions}}
{{if eq .QType "checkbox"}} @@ -682,6 +688,7 @@

{{call .T "booking_confirmed"}}

start_at: pickedSlot.start, name: form.name.value.trim(), email: form.email.value.trim(), + phone: form.elements.phone?.value.trim() || "", timezone: TZ, language: LOCALE, hp_extra: form.hp_extra.value, @@ -739,6 +746,7 @@

{{call .T "booking_confirmed"}}

host_name: (booked && Array.isArray(booked.hosts)) ? booked.hosts.map(function (h) { return h.name; }).join(', ') : '', attendee_name: form.name.value.trim(), attendee_email: form.email.value.trim(), + phone: form.elements.phone?.value.trim() || "", attendee_timezone: TZ, answers: dlAnswers, is_paid: false, diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 0252c927..bd1847bb 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -156,5 +156,6 @@ "assistant_iteration_cap": "Machen wir es einfach: Bitte wählen Sie einen Termin im Kalender unten.", "assistant_status_slots": "Verfügbarkeit wird geprüft…", "assistant_status_book": "Wird gebucht…", - "assistant_status_working": "Wird bearbeitet…" + "assistant_status_working": "Wird bearbeitet…", + "phone_call_number": "Telefonnummer (optional, für einen Anruf)" } diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 4b6a6399..167c10e5 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -163,5 +163,6 @@ "assistant_iteration_cap": "Let's keep it simple — please pick a time from the calendar below.", "assistant_status_slots": "Checking availability…", "assistant_status_book": "Booking…", - "assistant_status_working": "Working…" + "assistant_status_working": "Working…", + "phone_call_number": "Phone number (optional, for a phone call)" } diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index e7dc5d01..bd675f0e 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -163,5 +163,6 @@ "assistant_iteration_cap": "Vamos a simplificarlo: elige un horario en el calendario de abajo.", "assistant_status_slots": "Comprobando disponibilidad…", "assistant_status_book": "Reservando…", - "assistant_status_working": "Trabajando…" + "assistant_status_working": "Trabajando…", + "phone_call_number": "Teléfono (opcional, para una llamada)" } diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index af348cbd..341b7ca0 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -156,5 +156,6 @@ "assistant_iteration_cap": "Simplifions les choses : choisissez un créneau dans le calendrier ci-dessous.", "assistant_status_slots": "Vérification des disponibilités…", "assistant_status_book": "Réservation…", - "assistant_status_working": "En cours…" + "assistant_status_working": "En cours…", + "phone_call_number": "Téléphone (facultatif, pour un appel)" } diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index a53420cb..cf877c55 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -156,5 +156,6 @@ "assistant_iteration_cap": "Semplifichiamo: scelga un orario dal calendario qui sotto.", "assistant_status_slots": "Verifica della disponibilità…", "assistant_status_book": "Prenotazione in corso…", - "assistant_status_working": "Elaborazione…" + "assistant_status_working": "Elaborazione…", + "phone_call_number": "Telefono (facoltativo, per una chiamata)" } diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 85884aa9..d574659e 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -156,5 +156,6 @@ "assistant_iteration_cap": "Laten we het simpel houden: kies een tijd in de agenda hieronder.", "assistant_status_slots": "Beschikbaarheid controleren…", "assistant_status_book": "Boeken…", - "assistant_status_working": "Bezig…" + "assistant_status_working": "Bezig…", + "phone_call_number": "Telefoonnummer (optioneel, voor een telefoongesprek)" } diff --git a/internal/i18n/locales/pt.json b/internal/i18n/locales/pt.json index c8f43f0d..66d917d6 100644 --- a/internal/i18n/locales/pt.json +++ b/internal/i18n/locales/pt.json @@ -156,5 +156,6 @@ "assistant_iteration_cap": "Vamos simplificar: escolha um horário no calendário abaixo.", "assistant_status_slots": "A verificar a disponibilidade…", "assistant_status_book": "A marcar…", - "assistant_status_working": "A processar…" + "assistant_status_working": "A processar…", + "phone_call_number": "Telefone (opcional, para uma chamada)" } diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index 3cd404d8..c718acfc 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -156,5 +156,6 @@ "assistant_iteration_cap": "Vi tar det enkelt: välj en tid i kalendern nedan.", "assistant_status_slots": "Kontrollerar tillgängliga tider…", "assistant_status_book": "Bokar…", - "assistant_status_working": "Arbetar…" + "assistant_status_working": "Arbetar…", + "phone_call_number": "Telefonnummer (valfritt, för ett telefonsamtal)" }