Skip to content
Closed
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
56 changes: 56 additions & 0 deletions internal/handler/booking_answers_email_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
6 changes: 6 additions & 0 deletions internal/handler/booking_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions internal/handler/question_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/handler/reassign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions internal/handler/reassign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)`)
Expand All @@ -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")
Expand All @@ -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) {
Expand Down
16 changes: 13 additions & 3 deletions internal/mailer/booking.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}}

Expand Down
4 changes: 4 additions & 0 deletions internal/mailer/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ var htmlConfirmHost = content(`{{define "content"}}
<tr>` + labelTD + `When</td>` + valueTD + `{{.WhenFmt}}</td></tr>
{{if .LocationValue}}<tr>` + labelTD + `Location</td><td style="padding:5px 0;vertical-align:top;word-break:break-word;">{{.LocationValue}}</td></tr>{{end}}
{{template "cardClose" .}}
{{if .Answers}}<div style="margin:16px 0;padding-top:16px;border-top:1px solid #e4e4e7;font-size:14px;">
<p style="margin:0 0 8px;font-weight:600;">Booking answers</p>
{{range .Answers}}<p style="margin:0 0 12px;"><strong>{{.Label}}</strong><br><span style="white-space:pre-wrap;word-break:break-word;">{{.Value}}</span></p>{{end}}
</div>{{end}}
{{template "ref" .}}
{{end}}`)

Expand Down
24 changes: 24 additions & 0 deletions internal/mailer/mailer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ func TestSendConfirmation_hostEmail(t *testing.T) {
}
}

func TestSendConfirmation_hostIncludesBookingAnswersOnly(t *testing.T) {
cap := &captureMailer{}
d := testBookingData()
d.Answers = []BookingAnswer{{Label: "What should we discuss?", Value: "First line\n<script>alert(1)</script>"}}
if err := SendConfirmation(context.Background(), cap, d); err != nil {
t.Fatal(err)
}
msgs := cap.all()
for _, part := range []string{"Booking answers:", "What should we discuss?", "First line", "<script>alert(1)</script>"} {
if !strings.Contains(msgs[1].Text, part) {
t.Errorf("host text missing %q", part)
}
}
if !strings.Contains(msgs[1].HTML, "Booking answers") || !strings.Contains(msgs[1].HTML, "What should we discuss?") {
t.Error("host HTML missing the booking answer")
}
if strings.Contains(msgs[1].HTML, "<script>") || !strings.Contains(msgs[1].HTML, "&lt;script&gt;") {
t.Error("host HTML did not escape the booking answer")
}
if strings.Contains(msgs[0].Text, "What should we discuss?") || strings.Contains(msgs[0].HTML, "What should we discuss?") {
t.Error("attendee email unexpectedly contains intake answers")
}
}

func TestSendConfirmation_noHostEmail_skipsHostSend(t *testing.T) {
cap := &captureMailer{}
d := testBookingData()
Expand Down
Loading