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).
198 lines
6.0 KiB
Go
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")
|
|
}
|