feat(tier2): reminders + broadcasts pipeline — Block F

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>
This commit is contained in:
Kwaku Danso
2026-05-20 16:56:37 +01:00
parent dbddf17e3b
commit dc840bfc14
12 changed files with 1859 additions and 7 deletions
+316
View File
@@ -0,0 +1,316 @@
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// messageHandler is the host-facing surface for Tier 2 Block F:
// scheduled reminders + custom broadcasts. Editor+ for writes; viewer+
// for reads via the existing requireRole gate.
type messageHandler struct {
logger *slog.Logger
events *storage.EventRepo
collabs *storage.CollaboratorRepo
repo *storage.MessageRepo
}
// --- response shapes ---
// messageView wraps the persisted row with a delivery summary. We don't
// inline the per-recipient rows here — the host expands a single message
// to drill into those — but the "X of Y delivered" rollup is useful in
// the list view.
type messageView struct {
*domain.ScheduledMessage
DeliveryStats storage.DeliveryStats `json:"delivery_stats"`
}
type listMessagesResponse struct {
Messages []messageView `json:"messages"`
}
// GET /events/{id}/messages — viewer+.
func (h *messageHandler) list(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
msgs, err := h.repo.ListByEvent(r.Context(), eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list messages")
return
}
views := make([]messageView, 0, len(msgs))
for i := range msgs {
stats, _ := h.repo.DeliveryStats(r.Context(), msgs[i].ID)
views = append(views, messageView{
ScheduledMessage: &msgs[i],
DeliveryStats: stats,
})
}
writeJSON(w, http.StatusOK, listMessagesResponse{Messages: views})
}
// composeMessageRequest is what the Communications tab POSTs / PATCHes.
// SendAt is optional on create — omit it together with status="draft" to
// save a draft, or set it for scheduling. Channel defaults to email.
type composeMessageRequest struct {
SendAt *time.Time `json:"send_at"`
Audience domain.MessageAudience `json:"audience"`
Channel domain.MessageChannel `json:"channel"`
Subject string `json:"subject"`
Body string `json:"body"`
Draft bool `json:"draft"`
}
// recipientCountResponse is what the live "X guests will receive this"
// preview chip on the compose form fetches when the audience picker
// changes.
type recipientCountResponse struct {
Count int `json:"count"`
}
// GET /events/{id}/messages/recipient-count?audience=... — viewer+. Lets
// the compose form show a live count without needing to load the full
// guest list client-side.
func (h *messageHandler) recipientCount(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
audience := domain.MessageAudience(r.URL.Query().Get("audience"))
if !audience.Valid() {
writeError(w, http.StatusBadRequest, "audience must be one of: all|attending|pending|declined|maybe")
return
}
n, err := h.repo.CountRecipients(r.Context(), eventID, audience)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count recipients")
return
}
writeJSON(w, http.StatusOK, recipientCountResponse{Count: n})
}
// POST /events/{id}/messages — editor+.
//
// The host can save a draft (Draft=true, SendAt nil → status=draft) or
// schedule for later (SendAt set → status=scheduled). Send-immediately is
// just "schedule for now"; we expose a separate send-now route for the
// "send this draft right now" affordance below.
func (h *messageHandler) create(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
var req composeMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if !req.Audience.Valid() {
writeError(w, http.StatusBadRequest, "audience required (all|attending|pending|declined|maybe)")
return
}
if req.Channel == "" {
req.Channel = domain.ChannelEmail
}
if !req.Channel.Valid() {
writeError(w, http.StatusBadRequest, "channel must be one of: email|sms|both")
return
}
if req.Body == "" {
writeError(w, http.StatusBadRequest, "body is required")
return
}
// Pick the status from send_at + draft flag.
status := domain.StatusScheduled
sendAt := time.Now().UTC()
if req.Draft || req.SendAt == nil {
if req.Draft {
status = domain.StatusDraft
} else {
// No send_at given and not flagged as draft: send now.
status = domain.StatusScheduled
}
} else {
sendAt = req.SendAt.UTC()
}
subj := req.Subject
var subjPtr *string
if subj != "" {
subjPtr = &subj
}
m, err := h.repo.Create(r.Context(), storage.CreateMessageParams{
EventID: eventID,
SendAt: sendAt,
Audience: req.Audience,
Channel: req.Channel,
Subject: subjPtr,
Body: req.Body,
Status: status,
CreatedBy: &hostID,
})
if err != nil {
h.logger.Error("create message", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create message")
return
}
writeJSON(w, http.StatusCreated, m)
}
// PATCH /events/{id}/messages/{message_id} — editor+. Only legal while
// the message is still draft or scheduled (the storage layer enforces
// this with a row lock).
func (h *messageHandler) update(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
msgID, ok := parseIDParam(w, r, "message_id")
if !ok {
return
}
var req composeMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
params := storage.UpdateMessageParams{}
if req.SendAt != nil {
t := req.SendAt.UTC()
params.SendAt = &t
}
if req.Audience != "" {
if !req.Audience.Valid() {
writeError(w, http.StatusBadRequest, "invalid audience")
return
}
params.Audience = &req.Audience
}
if req.Channel != "" {
if !req.Channel.Valid() {
writeError(w, http.StatusBadRequest, "invalid channel")
return
}
params.Channel = &req.Channel
}
if req.Subject != "" {
params.Subject = &req.Subject
}
if req.Body != "" {
params.Body = &req.Body
}
m, err := h.repo.Update(r.Context(), eventID, msgID, params)
if err != nil {
switch {
case errors.Is(err, domain.ErrMessageNotFound):
writeError(w, http.StatusNotFound, "message not found")
case errors.Is(err, domain.ErrMessageNotEditable):
writeError(w, http.StatusConflict, "message can only be edited while scheduled or draft")
default:
h.logger.Error("update message", "err", err)
writeError(w, http.StatusInternalServerError, "failed to update message")
}
return
}
writeJSON(w, http.StatusOK, m)
}
// POST /events/{id}/messages/{message_id}/send-now — editor+.
// Promotes a draft or future-scheduled message to send_at=now, so the
// next scheduler poll picks it up.
func (h *messageHandler) sendNow(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
msgID, ok := parseIDParam(w, r, "message_id")
if !ok {
return
}
if err := h.repo.PromoteToScheduled(r.Context(), eventID, msgID); err != nil {
if errors.Is(err, domain.ErrMessageNotEditable) {
writeError(w, http.StatusConflict, "message has already been sent or cancelled")
return
}
writeError(w, http.StatusInternalServerError, "failed to schedule send")
return
}
w.WriteHeader(http.StatusAccepted)
}
// DELETE /events/{id}/messages/{message_id} — editor+. Cancels a
// scheduled / draft message. Sent or sending messages can't be deleted.
func (h *messageHandler) cancel(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
msgID, ok := parseIDParam(w, r, "message_id")
if !ok {
return
}
if err := h.repo.Cancel(r.Context(), eventID, msgID); err != nil {
if errors.Is(err, domain.ErrMessageNotEditable) {
writeError(w, http.StatusConflict, "message has already been sent")
return
}
writeError(w, http.StatusInternalServerError, "failed to cancel message")
return
}
w.WriteHeader(http.StatusNoContent)
}