// broadcasts.go — bulk team-email send (t-paliad-147 / issue #7). // // One write endpoint (/api/team/broadcast) and a pair of read endpoints // for the /admin/broadcasts viewer. // // The /api/team/broadcast handler enforces the project-lead-OR-global_admin // authorisation in BroadcastService.Send, so non-leads receive 403. package handlers import ( "encoding/json" "errors" "net/http" "strconv" "github.com/google/uuid" "mgit.msbls.de/m/paliad/internal/services" ) // broadcastRequest is the JSON body for POST /api/team/broadcast. // // Recipients carry the addresseelist as resolved on the client side: the // frontend filters the displayed team table, then submits the user_ids the // user wanted to mail. The server validates each address and rejects if // any is malformed. type broadcastRequest struct { ProjectID *uuid.UUID `json:"project_id,omitempty"` Subject string `json:"subject"` Body string `json:"body"` TemplateKey string `json:"template_key,omitempty"` Lang string `json:"lang,omitempty"` RecipientFilter map[string]any `json:"recipient_filter,omitempty"` Recipients []broadcastRequestRecipient `json:"recipients"` } type broadcastRequestRecipient struct { UserID uuid.UUID `json:"user_id"` Email string `json:"email"` DisplayName string `json:"display_name"` FirstName string `json:"first_name"` RoleOnProject string `json:"role_on_project"` } // POST /api/team/broadcast — dispatch a personalised email to a filtered // team subset. Returns the broadcast ID and per-recipient send report. func handleTeamBroadcast(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } if dbSvc.broadcast == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "broadcasts unavailable — broadcast service not configured", }) return } uid, ok := requireUser(w, r) if !ok { return } var req broadcastRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } in := services.BroadcastInput{ ProjectID: req.ProjectID, Subject: req.Subject, Body: req.Body, TemplateKey: req.TemplateKey, Lang: req.Lang, RecipientFilter: req.RecipientFilter, Recipients: make([]services.BroadcastRecipient, 0, len(req.Recipients)), } for _, rc := range req.Recipients { in.Recipients = append(in.Recipients, services.BroadcastRecipient{ UserID: rc.UserID, Email: rc.Email, DisplayName: rc.DisplayName, FirstName: rc.FirstName, RoleOnProject: rc.RoleOnProject, }) } report, err := dbSvc.broadcast.Send(r.Context(), uid, in) if err != nil { switch { case errors.Is(err, services.ErrBroadcastForbidden): writeJSON(w, http.StatusForbidden, map[string]string{ "error": "only project leads or global admins can send broadcasts", }) case errors.Is(err, services.ErrBroadcastNoRecipients): writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "no recipients selected", }) case errors.Is(err, services.ErrBroadcastTooManyRecipients): writeJSON(w, http.StatusUnprocessableEntity, map[string]string{ "error": err.Error(), }) case errors.Is(err, services.ErrBroadcastEmptySubject): writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "subject is required", }) case errors.Is(err, services.ErrBroadcastEmptyBody): writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "body is required", }) case errors.Is(err, services.ErrBroadcastInvalidEmail): writeJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) default: writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to send broadcast", }) } return } writeJSON(w, http.StatusCreated, report) } // GET /api/admin/broadcasts — list broadcasts visible to the caller. // global_admin sees all rows; senders see their own. // // Lives behind the gateOnboarded gate (not adminGate) so a project lead // who's never been promoted to global_admin can still see their own // sends. func handleListBroadcasts(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } if dbSvc.broadcast == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "broadcasts unavailable", }) return } uid, ok := requireUser(w, r) if !ok { return } limit := 50 if v := r.URL.Query().Get("limit"); v != "" { if parsed, err := strconv.Atoi(v); err == nil { limit = parsed } } rows, err := dbSvc.broadcast.List(r.Context(), uid, limit) if err != nil { if errors.Is(err, services.ErrBroadcastForbidden) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) return } writeServiceError(w, err) return } writeJSON(w, http.StatusOK, rows) } // GET /api/admin/broadcasts/{id} — full detail for one broadcast. func handleGetBroadcast(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } if dbSvc.broadcast == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "broadcasts unavailable", }) return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } detail, err := dbSvc.broadcast.Get(r.Context(), uid, id) if err != nil { if errors.Is(err, services.ErrBroadcastForbidden) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) return } writeServiceError(w, err) return } writeJSON(w, http.StatusOK, detail) } // GET /admin/broadcasts — server-rendered shell. func handleAdminBroadcastsPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "dist/admin-broadcasts.html") }