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:
@@ -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)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ type Server struct {
|
||||
branding *brandingHandler
|
||||
uploads *uploadHandler
|
||||
security *securityHandler
|
||||
messages *messageHandler
|
||||
}
|
||||
|
||||
type ServerDeps struct {
|
||||
@@ -103,6 +104,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
brandingRepo := storage.NewBrandingRepo(deps.DB)
|
||||
allowlistRepo := storage.NewAllowlistRepo(deps.DB)
|
||||
editNonces := newEditNonceStore(deps.Redis)
|
||||
messageRepo := storage.NewMessageRepo(deps.DB)
|
||||
feedbackRepo := storage.NewFeedbackRepo(deps.DB)
|
||||
|
||||
// Branding image store. Empty UploadsDir leaves it nil and the upload
|
||||
@@ -275,6 +277,12 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
feedback: feedbackRepo,
|
||||
access: accessRepo,
|
||||
},
|
||||
messages: &messageHandler{
|
||||
logger: deps.Logger,
|
||||
events: eventRepo,
|
||||
collabs: collabRepo,
|
||||
repo: messageRepo,
|
||||
},
|
||||
collabs: &collaboratorHandler{
|
||||
logger: deps.Logger,
|
||||
events: eventRepo,
|
||||
@@ -391,6 +399,21 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /events/{id}/access-logs/{log_id}/feedback",
|
||||
authed(http.HandlerFunc(s.security.recordFeedback)))
|
||||
|
||||
// Block F — scheduled messages (reminders + broadcasts).
|
||||
// All editor+ except the recipient-count preview which is viewer+.
|
||||
mux.Handle("GET /events/{id}/messages",
|
||||
authed(http.HandlerFunc(s.messages.list)))
|
||||
mux.Handle("GET /events/{id}/messages/recipient-count",
|
||||
authed(http.HandlerFunc(s.messages.recipientCount)))
|
||||
mux.Handle("POST /events/{id}/messages",
|
||||
authed(rl("messages_create", 100, 24*time.Hour, userIDKey, http.HandlerFunc(s.messages.create))))
|
||||
mux.Handle("PATCH /events/{id}/messages/{message_id}",
|
||||
authed(http.HandlerFunc(s.messages.update)))
|
||||
mux.Handle("POST /events/{id}/messages/{message_id}/send-now",
|
||||
authed(http.HandlerFunc(s.messages.sendNow)))
|
||||
mux.Handle("DELETE /events/{id}/messages/{message_id}",
|
||||
authed(http.HandlerFunc(s.messages.cancel)))
|
||||
|
||||
// Block D — event branding. Reads are viewer+; PUT is editor+. The
|
||||
// upload endpoint is gated by auth only (any signed-in user can mint
|
||||
// an image URL; the URL is no use without an event they can edit
|
||||
|
||||
Reference in New Issue
Block a user