From 16f49d6538441e2f736dbe3b12f8869598273929 Mon Sep 17 00:00:00 2001
From: Marijn Bent
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 @@