Files
paliad/internal/handlers/broadcasts.go
m 52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00

198 lines
6.0 KiB
Go

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