dc840bfc14
The Communications surface. Hosts can schedule custom broadcasts to a
chosen audience (everyone / attending / pending / declined / maybe),
edit or cancel anything that hasn't fired, and review delivery
outcomes. Four auto-reminders are pre-seeded on every new event:
7-day, 3-day last call, 1-day, and day-of.
Schema (migration 0012)
- scheduled_messages — one row per message envelope, with status
walking draft -> scheduled -> sending -> sent (or cancelled/failed).
Partial index on (send_at) WHERE status='scheduled' for the
scheduler poll; per-event index for the Communications tab list.
- message_deliveries — per-recipient outcomes so a partial-failure
batch doesn't lose the rows that did succeed.
Domain
- MessageAudience / MessageChannel / MessageStatus enums
- SeedAutoReminders helper that returns four canonical reminder rows
for a given event_date, skipping any whose send_at would land in
the past (events created close to the date)
Storage
- MessageRepo: Create / CreateBatch / Get / ListByEvent / Update
(locks the row and refuses unless status is draft|scheduled) /
Cancel / PromoteToScheduled (the send-now path) / ListDue /
ClaimForSending (atomic guard against two replicas double-sending) /
MarkSent / MarkFailed / RecordDelivery / DeliveryStats /
LoadRecipients (audience-filtered guest list) / CountRecipients
- EventRepo.Create now seeds auto-reminders in the same transaction
that inserts the event and its owner collaborator row
API (all editor+, except recipient-count which is viewer+)
- GET /events/{id}/messages
- GET /events/{id}/messages/recipient-count?audience=...
- POST /events/{id}/messages (draft / schedule / send-now)
- PATCH /events/{id}/messages/{message_id}
- POST /events/{id}/messages/{message_id}/send-now
- DELETE /events/{id}/messages/{message_id}
Scheduler worker (cmd/notifier)
- New file scheduler.go: polls ListDue every 30s, claims each row
atomically (ClaimForSending uses a status=scheduled guard so two
notifier replicas don't double-send), renders subject and body
per recipient with the {{guest_name}} / {{event_name}} /
{{event_date}} / {{venue}} / {{rsvp_link}} placeholders, sends via
the existing GuestEmailDispatcher (Resend > SMTP > SES > log
stub, same picker as the API), records each delivery row.
Frontend
- New CommunicationsCard.vue with compose form (audience + channel +
subject + body + send-mode radios), live "X guests will receive
this" recipient-count preview, and three sub-tabs for Scheduled /
Sent / Cancelled. Per-message Send-now and Cancel actions for
draft/scheduled rows. Friendly labels for auto-seeded reminders
("1-day reminder", "Day-of reminder") so the slugs never leak.
- New top-level tab "Communications" on the event-detail page,
between Collaborators and Branding.
Tests
- TestAutoReminderSeeding confirms a future-dated event lands the
four canonical reminders in scheduled state.
- TestComposeAndEditMessage walks draft -> patch -> send-now ->
cancel and asserts the conflict on PATCH-after-cancel.
- TestRecipientCountAudienceFilter seeds a known guest mix and
checks every audience preset returns the right count.
- Full integration suite passes (~177s).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
169 lines
5.2 KiB
Go
169 lines
5.2 KiB
Go
//go:build integration
|
|
|
|
package integration_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Tier 2 Block F — scheduled messages (reminders + broadcasts).
|
|
|
|
type messagePersisted struct {
|
|
ID uuid.UUID `json:"id"`
|
|
EventID uuid.UUID `json:"event_id"`
|
|
Audience string `json:"audience"`
|
|
Channel string `json:"channel"`
|
|
Status string `json:"status"`
|
|
TemplateKey *string `json:"template_key"`
|
|
Subject *string `json:"subject"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
type listMessagesBody struct {
|
|
Messages []struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Status string `json:"status"`
|
|
TemplateKey *string `json:"template_key"`
|
|
Body string `json:"body"`
|
|
} `json:"messages"`
|
|
}
|
|
|
|
// TestAutoReminderSeeding confirms that creating an event in the future
|
|
// also seeds the reminder schedule. The seed is conservative: only
|
|
// reminders whose send_at would fall after "now" land in the DB.
|
|
func TestAutoReminderSeeding(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in -short mode")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
srv, _, _, token := setupAuthedAPI(t, ctx)
|
|
eventID := createEvent(t, srv.URL, token, "Far Future", "far-future")
|
|
// createEvent defaults event_date to +30d, so all four templates
|
|
// (7d / last_call / 1d / dayof) should seed.
|
|
|
|
var listed listMessagesBody
|
|
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/messages", srv.URL, eventID),
|
|
token, http.StatusOK, &listed)
|
|
if len(listed.Messages) < 3 {
|
|
t.Fatalf("expected at least 3 auto-reminders, got %d", len(listed.Messages))
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, m := range listed.Messages {
|
|
if m.TemplateKey != nil {
|
|
seen[*m.TemplateKey] = true
|
|
}
|
|
if m.Status != "scheduled" {
|
|
t.Errorf("auto-reminder status: got %q want scheduled", m.Status)
|
|
}
|
|
}
|
|
for _, want := range []string{"reminder_7d", "last_call", "reminder_1d", "reminder_dayof"} {
|
|
if !seen[want] {
|
|
t.Errorf("missing auto-reminder template %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestComposeAndEditMessage walks the create -> list -> patch -> cancel
|
|
// path. Confirms the status machine: edits OK while scheduled, send-now
|
|
// flips to scheduled with send_at=now, cancel turns it to cancelled and
|
|
// blocks further edits.
|
|
func TestComposeAndEditMessage(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in -short mode")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
srv, _, _, token := setupAuthedAPI(t, ctx)
|
|
eventID := createEvent(t, srv.URL, token, "Compose", "compose-test")
|
|
|
|
// Compose a draft.
|
|
var created messagePersisted
|
|
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/messages", srv.URL, eventID),
|
|
token,
|
|
map[string]any{
|
|
"audience": "pending",
|
|
"channel": "email",
|
|
"subject": "Pls reply",
|
|
"body": "Hi {{guest_name}}, we'd love to know if you can make it!",
|
|
"draft": true,
|
|
},
|
|
http.StatusCreated, &created)
|
|
if created.Status != "draft" {
|
|
t.Fatalf("expected draft status, got %q", created.Status)
|
|
}
|
|
|
|
// Patch the body. Status: still draft.
|
|
patchJSON(t, fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
|
|
token,
|
|
map[string]any{
|
|
"body": "Updated copy",
|
|
},
|
|
http.StatusOK, nil)
|
|
|
|
// Send-now: promotes the draft to scheduled with send_at=now.
|
|
assertStatus(t, http.MethodPost,
|
|
fmt.Sprintf("%s/events/%s/messages/%s/send-now", srv.URL, eventID, created.ID),
|
|
token, nil, http.StatusAccepted)
|
|
|
|
// Cancel: works while scheduled.
|
|
assertStatus(t, http.MethodDelete,
|
|
fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
|
|
token, nil, http.StatusNoContent)
|
|
|
|
// PATCH on a cancelled row is refused.
|
|
assertStatus(t, http.MethodPatch,
|
|
fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
|
|
token,
|
|
map[string]any{"body": "too late"},
|
|
http.StatusConflict)
|
|
}
|
|
|
|
// TestRecipientCountAudienceFilter confirms the live preview returns
|
|
// counts matching the audience filter.
|
|
func TestRecipientCountAudienceFilter(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in -short mode")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
srv, db, _, token := setupAuthedAPI(t, ctx)
|
|
eventID := createEvent(t, srv.URL, token, "Audience", "audience-test")
|
|
|
|
// Seed five guests: 2 attending, 1 declined, 1 maybe, 1 pending.
|
|
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "A1", 2, "attending", 0, true)
|
|
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "A2", 0, "attending", 1, true)
|
|
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "D1", 0, "declined", 0, true)
|
|
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "M1", 0, "maybe", 0, true)
|
|
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "P1", 0, "", 0, false)
|
|
|
|
cases := map[string]int{
|
|
"all": 5,
|
|
"attending": 2,
|
|
"declined": 1,
|
|
"maybe": 1,
|
|
"pending": 1,
|
|
}
|
|
for audience, want := range cases {
|
|
var got struct {
|
|
Count int `json:"count"`
|
|
}
|
|
getJSONAuthed(t,
|
|
fmt.Sprintf("%s/events/%s/messages/recipient-count?audience=%s",
|
|
srv.URL, eventID, audience),
|
|
token, http.StatusOK, &got)
|
|
if got.Count != want {
|
|
t.Errorf("audience=%s: got %d want %d", audience, got.Count, want)
|
|
}
|
|
}
|
|
}
|