feat: broadcast
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// BroadcastHandler serves both sides of announcements: the admin routes that
|
||||
// compose and publish them, and the two app routes that fetch and dismiss.
|
||||
type BroadcastHandler struct {
|
||||
svc *service.BroadcastService
|
||||
}
|
||||
|
||||
func NewBroadcastHandler(svc *service.BroadcastService) *BroadcastHandler {
|
||||
return &BroadcastHandler{svc: svc}
|
||||
}
|
||||
|
||||
func writeBroadcastError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrBroadcastGone):
|
||||
response.Error(w, http.StatusNotFound, "broadcast not found")
|
||||
case errors.Is(err, service.ErrBroadcastTitle),
|
||||
errors.Is(err, service.ErrBroadcastBody),
|
||||
errors.Is(err, service.ErrBroadcastCTA),
|
||||
errors.Is(err, service.ErrBroadcastBanner):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "broadcast request failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── App routes ────────────────────────────────────────────────────────────
|
||||
|
||||
// Pending — GET /broadcasts/pending. Returns null when nothing is waiting, so
|
||||
// the client can call it on load and after a nudge without special-casing.
|
||||
func (h *BroadcastHandler) Pending(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := h.svc.Pending(r.Context(), middleware.UserID(r.Context()))
|
||||
if err != nil {
|
||||
writeBroadcastError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// Seen — POST /broadcasts/{id}/seen. The "at most once" half; idempotent.
|
||||
func (h *BroadcastHandler) Seen(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.MarkSeen(r.Context(), chi.URLParam(r, "id"),
|
||||
middleware.UserID(r.Context())); err != nil {
|
||||
writeBroadcastError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ── Admin routes ──────────────────────────────────────────────────────────
|
||||
|
||||
type createBroadcastReq struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
BannerKey *string `json:"banner_key"`
|
||||
CTALabel *string `json:"cta_label"`
|
||||
CTAURL *string `json:"cta_url"`
|
||||
// ExpiresAt stops the announcement being offered. Without one, someone
|
||||
// signing up months later would still be shown it.
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
// Publish immediately rather than saving a draft.
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
|
||||
// Create — POST /admin/broadcasts
|
||||
func (h *BroadcastHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req createBroadcastReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
b, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), service.CreateParams{
|
||||
Title: req.Title, Body: req.Body, BannerKey: req.BannerKey,
|
||||
CTALabel: req.CTALabel, CTAURL: req.CTAURL, ExpiresAt: req.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
writeBroadcastError(w, err)
|
||||
return
|
||||
}
|
||||
if req.Publish {
|
||||
published, err := h.svc.Publish(r.Context(), b.ID)
|
||||
if err != nil {
|
||||
writeBroadcastError(w, err)
|
||||
return
|
||||
}
|
||||
b = published
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, b)
|
||||
}
|
||||
|
||||
// Publish — POST /admin/broadcasts/{id}/publish
|
||||
func (h *BroadcastHandler) Publish(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := h.svc.Publish(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeBroadcastError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// List — GET /admin/broadcasts
|
||||
func (h *BroadcastHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.svc.List(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeBroadcastError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// Delete — DELETE /admin/broadcasts/{id}
|
||||
func (h *BroadcastHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
|
||||
writeBroadcastError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// PresignBanner — POST /admin/broadcasts/banner-presign
|
||||
func (h *BroadcastHandler) PresignBanner(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
upload, key, err := h.svc.PresignBanner(r.Context(), strings.TrimSpace(req.ContentType))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
|
||||
}
|
||||
Reference in New Issue
Block a user