diff --git a/internal/handler/booking_answers_email_test.go b/internal/handler/booking_answers_email_test.go new file mode 100644 index 00000000..37f054ae --- /dev/null +++ b/internal/handler/booking_answers_email_test.go @@ -0,0 +1,56 @@ +package handler_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/calnode/calnode/internal/mailer" +) + +type bookingEmailCapture struct{ messages chan mailer.Message } + +func (c bookingEmailCapture) Send(_ context.Context, msg mailer.Message) error { + c.messages <- msg + return nil +} + +func TestCreateBooking_hostEmailIncludesSavedAnswer(t *testing.T) { + h, _, key, _ := setupWorkspaceWithDB(t) + cap := bookingEmailCapture{messages: make(chan mailer.Message, 4)} + h.SetMailer(cap, "https://book.example.com") + slug, _ := seedEventTypeHTTP(t, h, key) + questionID := createQuestion(t, h, slug, key, `{"label":"What should we discuss?","type":"text","required":true}`) + + body := fmt.Sprintf(`{"event_type_slug":%q,"start_at":"2026-06-20T10:00:00Z","name":"Guest","email":"guest@example.com","answers":[{"question_id":%q,"value":"The project scope"}]}`, slug, questionID) + req := httptest.NewRequest(http.MethodPost, "/v1/bookings", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.CreateBooking(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create booking: %d — %s", rec.Code, rec.Body.String()) + } + + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case msg := <-cap.messages: + if len(msg.To) == 0 || msg.To[0] != "host@example.com" { + continue + } + for _, part := range []string{"What should we discuss?", "The project scope"} { + if !strings.Contains(msg.Text, part) || !strings.Contains(msg.HTML, part) { + t.Errorf("host notification missing %q in text or HTML", part) + } + } + return + case <-timer.C: + t.Fatal("timed out waiting for host notification") + } + } +} diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index 02a7ef99..f57f8cac 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -1327,6 +1327,12 @@ func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConf if subjNote.Valid { bData.SubjectOverride = subjNote.String } + answers, answersErr := h.bookingAnswersForEmail(ctx, b.ID) + if answersErr != nil { + h.logger.Error("booking confirmation: load answers", "error", answersErr, "booking_id", b.ID) + } else { + bData.Answers = answers + } meetURL, autoGenMeet, livekitHostURL := h.mintMeetingLink(ctx, b, in, &bData, hosts) primaryPrefs, hostFailed := h.createHostEventsAndNotify(ctx, b, in, &bData, hosts, meetURL, autoGenMeet, livekitHostURL) diff --git a/internal/handler/question_handler.go b/internal/handler/question_handler.go index 73268df2..e742f62c 100644 --- a/internal/handler/question_handler.go +++ b/internal/handler/question_handler.go @@ -11,6 +11,7 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/i18n" + "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/uid" ) @@ -412,6 +413,34 @@ func (h *Handler) GetBookingAnswers(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +// bookingAnswersForEmail loads saved intake replies with their question labels +// in form order for host booking notifications. +func (h *Handler) bookingAnswersForEmail(ctx context.Context, bookingID string) ([]mailer.BookingAnswer, error) { + rows, err := h.db.QueryContext(ctx, ` + SELECT q.label, a.value + FROM booking_answers a + JOIN event_type_questions q ON q.id = a.question_id + WHERE a.booking_id = ? + ORDER BY q.position, q.id`, bookingID) + if err != nil { + return nil, fmt.Errorf("load booking answers: %w", err) + } + defer rows.Close() + + var answers []mailer.BookingAnswer + for rows.Next() { + var answer mailer.BookingAnswer + if err := rows.Scan(&answer.Label, &answer.Value); err != nil { + return nil, fmt.Errorf("scan booking answer: %w", err) + } + answers = append(answers, answer) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read booking answers: %w", err) + } + return answers, nil +} + // scanQuestion scans a row from event_type_questions into a questionJSON. type questionScanner interface { Scan(dest ...any) error diff --git a/internal/handler/reassign.go b/internal/handler/reassign.go index 2a7b893b..d32e1c6f 100644 --- a/internal/handler/reassign.go +++ b/internal/handler/reassign.go @@ -213,6 +213,12 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { h.applyInviteDelivery(ctx, &d, inviteMode, newHostID) d.ICSSequence = int(time.Now().Unix()) } + if answers, err := h.bookingAnswersForEmail(ctx, bCopy.ID); err != nil { + h.logger.Error("reassign: load booking answers", "error", err, "booking_id", bCopy.ID) + } else { + d.Answers = answers + } + prefs := h.hostPrefsOrDefault(ctx, bCopy.ID, newHostID) if prefs.NotifyConfirmation { if err := mailer.SendConfirmationToAttendee(ctx, h.mailer, d); err != nil { diff --git a/internal/handler/reassign_test.go b/internal/handler/reassign_test.go index 46641182..f42880c9 100644 --- a/internal/handler/reassign_test.go +++ b/internal/handler/reassign_test.go @@ -4,11 +4,17 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "time" + + "github.com/calnode/calnode/internal/mailer" ) func TestReassignBooking_movesHost(t *testing.T) { h, database, ownerKey, _ := setupWorkspaceWithDB(t) + cap := bookingEmailCapture{messages: make(chan mailer.Message, 4)} + h.SetMailer(cap, "https://book.example.com") // Two members who can host. database.Exec(`INSERT INTO users (id,email,name,iana_timezone,is_admin) VALUES ('u2','h2@example.com','Host2','UTC',0)`) database.Exec(`INSERT INTO users (id,email,name,iana_timezone,is_admin) VALUES ('u3','h3@example.com','Host3','UTC',0)`) @@ -17,6 +23,14 @@ func TestReassignBooking_movesHost(t *testing.T) { VALUES ('b1','et1','u2','2099-01-01T10:00:00Z','2099-01-01T10:30:00Z','confirmed')`) database.Exec(`INSERT INTO booking_attendees (id,booking_id,name,email,iana_timezone,is_organizer) VALUES ('a1','b1','Alice','alice@example.com','UTC',1)`) + if _, err := database.Exec(`INSERT INTO event_type_questions (id,event_type_id,label,type) + VALUES ('q1','et1','What should we discuss?','text')`); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`INSERT INTO booking_answers (id,booking_id,question_id,value) + VALUES ('answer1','b1','q1','The project scope')`); err != nil { + t.Fatal(err) + } req := authReq(http.MethodPost, "/v1/bookings/b1/reassign", `{"host_id":"u3"}`, ownerKey) req.SetPathValue("id", "b1") @@ -31,6 +45,24 @@ func TestReassignBooking_movesHost(t *testing.T) { if hostID != "u3" { t.Errorf("host_id = %q; want u3", hostID) } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for { + select { + case msg := <-cap.messages: + if len(msg.To) == 0 || msg.To[0] != "h3@example.com" { + continue + } + for _, part := range []string{"What should we discuss?", "The project scope"} { + if !strings.Contains(msg.Text, part) || !strings.Contains(msg.HTML, part) { + t.Errorf("reassigned host email missing %q in text or HTML", part) + } + } + return + case <-deadline.C: + t.Fatal("timed out waiting for reassigned host email") + } + } } func TestReassignBooking_conflictWhenNewHostBusy(t *testing.T) { diff --git a/internal/mailer/booking.go b/internal/mailer/booking.go index 7791b310..417c10e3 100644 --- a/internal/mailer/booking.go +++ b/internal/mailer/booking.go @@ -12,6 +12,12 @@ import ( "github.com/calnode/calnode/internal/i18n" ) +// BookingAnswer is an intake reply shown in host booking notifications. +type BookingAnswer struct { + Label string + Value string +} + // BookingData carries all the information needed to render booking emails. type BookingData struct { BookingID string @@ -30,8 +36,9 @@ type BookingData struct { CancellationReason string ManageURL string // manage link (reschedule/cancel), set at booking creation BaseURL string - CustomNote string // optional host-configured note appended to the email body - SubjectOverride string // optional per-event-type custom subject; falls back to the default when empty + CustomNote string // optional host-configured note appended to the email body + Answers []BookingAnswer // guest's intake answers, shown only in host confirmation + SubjectOverride string // optional per-event-type custom subject; falls back to the default when empty // AttachICS attaches an iCalendar invite to the attendee's email — set by the // handler when the host has no Google destination calendar (so Google isn't already // inviting the attendee, which would duplicate), or when the booking's invites are @@ -469,7 +476,10 @@ Event: {{.EventTypeName}} With: {{.OrganizerName}} <{{.OrganizerEmail}}> Start: {{.StartFmt}} End: {{.EndFmt}}{{if .LocationValue}} -Location: {{.LocationValue}}{{end}} +Location: {{.LocationValue}}{{end}}{{if .Answers}} + +Booking answers:{{range .Answers}} +{{.Label}}: {{.Value}}{{end}}{{end}} Booking reference: {{.BookingID}} diff --git a/internal/mailer/html.go b/internal/mailer/html.go index 1f5b787c..a31001c0 100644 --- a/internal/mailer/html.go +++ b/internal/mailer/html.go @@ -90,6 +90,10 @@ var htmlConfirmHost = content(`{{define "content"}}
Booking answers
+{{range .Answers}}{{.Label}}
{{.Value}}