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>
183 lines
6.1 KiB
Go
183 lines
6.1 KiB
Go
package domain
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Tier 2 Block F — reminders + broadcasts.
|
|
|
|
// MessageAudience picks which slice of an event's guests a message
|
|
// targets. "All" means every guest; the response-filtered values mirror
|
|
// the RSVP states.
|
|
type MessageAudience string
|
|
|
|
const (
|
|
AudienceAll MessageAudience = "all"
|
|
AudienceAttending MessageAudience = "attending"
|
|
AudiencePending MessageAudience = "pending"
|
|
AudienceDeclined MessageAudience = "declined"
|
|
AudienceMaybe MessageAudience = "maybe"
|
|
)
|
|
|
|
func (a MessageAudience) Valid() bool {
|
|
switch a {
|
|
case AudienceAll, AudienceAttending, AudiencePending, AudienceDeclined, AudienceMaybe:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MessageChannel is the delivery surface. SMS is gated by tier elsewhere
|
|
// (Tier 1 Block F); email is universal.
|
|
type MessageChannel string
|
|
|
|
const (
|
|
ChannelEmail MessageChannel = "email"
|
|
ChannelSMS MessageChannel = "sms"
|
|
ChannelBoth MessageChannel = "both"
|
|
)
|
|
|
|
func (c MessageChannel) Valid() bool {
|
|
switch c {
|
|
case ChannelEmail, ChannelSMS, ChannelBoth:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MessageStatus walks the envelope through its life: draft (composed
|
|
// but not scheduled), scheduled (queued for the worker), sending (worker
|
|
// picked it up), sent (all deliveries attempted), cancelled (host
|
|
// pulled it), failed (worker couldn't proceed at all).
|
|
type MessageStatus string
|
|
|
|
const (
|
|
StatusDraft MessageStatus = "draft"
|
|
StatusScheduled MessageStatus = "scheduled"
|
|
StatusSending MessageStatus = "sending"
|
|
StatusSent MessageStatus = "sent"
|
|
StatusCancelled MessageStatus = "cancelled"
|
|
StatusFailed MessageStatus = "failed"
|
|
)
|
|
|
|
// ScheduledMessage is one host-composed (or auto-seeded) communication
|
|
// to a slice of an event's guests.
|
|
type ScheduledMessage struct {
|
|
ID uuid.UUID `json:"id"`
|
|
EventID uuid.UUID `json:"event_id"`
|
|
SendAt time.Time `json:"send_at"`
|
|
Audience MessageAudience `json:"audience"`
|
|
Channel MessageChannel `json:"channel"`
|
|
TemplateKey *string `json:"template_key,omitempty"`
|
|
Subject *string `json:"subject,omitempty"`
|
|
Body string `json:"body"`
|
|
Status MessageStatus `json:"status"`
|
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
|
RecipientCount *int `json:"recipient_count,omitempty"`
|
|
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// MessageDelivery is one recipient's outcome within a message batch.
|
|
// Status here is a free-form string rather than an enum so the notifier
|
|
// can record provider-specific reasons (bounce / suppressed / opted-out
|
|
// etc.) without a new migration each time.
|
|
type MessageDelivery struct {
|
|
MessageID uuid.UUID `json:"message_id"`
|
|
GuestID uuid.UUID `json:"guest_id"`
|
|
Status string `json:"status"`
|
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Auto-reminder template keys. The scheduler treats these as hints when
|
|
// it picks subject lines / formats; the body can be edited by the host
|
|
// just like a custom broadcast.
|
|
const (
|
|
TemplateReminder7d = "reminder_7d"
|
|
TemplateReminder1d = "reminder_1d"
|
|
TemplateReminderDayOf = "reminder_dayof"
|
|
TemplateLastCall = "last_call"
|
|
)
|
|
|
|
// SeedAutoReminders returns the canonical set of auto-reminder rows for
|
|
// an event with the given date. Each row is returned without an ID;
|
|
// the caller (EventRepo.Create) inserts them. Rows whose send_at is in
|
|
// the past are dropped — a host who creates an event 2 days from the
|
|
// big day shouldn't get a "7 days to go" reminder scheduled into
|
|
// yesterday.
|
|
func SeedAutoReminders(eventID uuid.UUID, eventDate time.Time) []ScheduledMessage {
|
|
now := time.Now().UTC()
|
|
candidates := []struct {
|
|
key string
|
|
offset time.Duration
|
|
audience MessageAudience
|
|
subject string
|
|
body string
|
|
}{
|
|
{
|
|
key: TemplateReminder7d,
|
|
offset: -7 * 24 * time.Hour,
|
|
audience: AudiencePending,
|
|
subject: "One week to go — please let us know!",
|
|
body: "Hi {{guest_name}},\n\nThe big day for {{event_name}} is just a week away. We haven't heard from you yet — could you let us know whether you'll make it? Just tap the link below.\n\n{{rsvp_link}}\n\nThanks!",
|
|
},
|
|
{
|
|
key: TemplateLastCall,
|
|
offset: -3 * 24 * time.Hour,
|
|
audience: AudiencePending,
|
|
subject: "Last call to RSVP for {{event_name}}",
|
|
body: "Hi {{guest_name}},\n\n{{event_name}} is just three days away and we're finalising numbers with the venue. Please RSVP today:\n\n{{rsvp_link}}",
|
|
},
|
|
{
|
|
key: TemplateReminder1d,
|
|
offset: -1 * 24 * time.Hour,
|
|
audience: AudienceAttending,
|
|
subject: "Tomorrow! Quick details for {{event_name}}",
|
|
body: "Hi {{guest_name}},\n\nLooking forward to seeing you tomorrow at {{event_name}}.\n\nWhere: {{venue}}\nWhen: {{event_date}}\n\nSafe travels!",
|
|
},
|
|
{
|
|
key: TemplateReminderDayOf,
|
|
offset: -3 * time.Hour,
|
|
audience: AudienceAttending,
|
|
subject: "See you in a few hours at {{event_name}}",
|
|
body: "Hi {{guest_name}},\n\nWe're getting ready for you. {{event_name}} kicks off shortly at {{venue}}.\n\nSee you soon!",
|
|
},
|
|
}
|
|
|
|
out := make([]ScheduledMessage, 0, len(candidates))
|
|
for _, c := range candidates {
|
|
sendAt := eventDate.Add(c.offset)
|
|
// Skip reminders that would fire in the past — typical when an
|
|
// event is created close to the date. The host can still
|
|
// compose a custom broadcast.
|
|
if sendAt.Before(now) {
|
|
continue
|
|
}
|
|
key := c.key
|
|
subj := c.subject
|
|
out = append(out, ScheduledMessage{
|
|
EventID: eventID,
|
|
SendAt: sendAt,
|
|
Audience: c.audience,
|
|
Channel: ChannelEmail,
|
|
TemplateKey: &key,
|
|
Subject: &subj,
|
|
Body: c.body,
|
|
Status: StatusScheduled,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
var (
|
|
ErrMessageNotFound = errors.New("message not found")
|
|
ErrMessageNotEditable = errors.New("message can only be edited while scheduled")
|
|
ErrInvalidAudience = errors.New("invalid audience")
|
|
ErrInvalidChannel = errors.New("invalid channel")
|
|
)
|