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