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") )