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>
317 lines
8.9 KiB
Go
317 lines
8.9 KiB
Go
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)
|
|
}
|