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).
192 lines
6.1 KiB
Go
192 lines
6.1 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
// TestBroadcastService_SendAndAudit_Live exercises the full BroadcastService
|
|
// pipeline against a real Postgres: the row lands in paliad.email_broadcasts,
|
|
// the send_report jsonb captures per-recipient outcomes, and List/Get
|
|
// honours the visibility rules (sender sees own; global_admin sees all).
|
|
//
|
|
// SMTP delivery is not exercised — the MailService is left disabled
|
|
// (Enabled() == false) so sendOne short-circuits cleanly. That's the same
|
|
// contract the dev/preview deploys run under.
|
|
//
|
|
// Skipped when TEST_DATABASE_URL is unset.
|
|
func TestBroadcastService_SendAndAudit_Live(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
|
}
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
leadID := uuid.New()
|
|
memberID := uuid.New()
|
|
otherSenderID := uuid.New()
|
|
projectID := uuid.New()
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
|
VALUES ($1, $2, 'Bcast Lead', 'munich', 'standard', 'de'),
|
|
($3, $4, 'Bcast Mem', 'munich', 'standard', 'de'),
|
|
($5, $6, 'Bcast Admin', 'munich', 'global_admin', 'de')`,
|
|
leadID, "bcast-lead@hlc.com",
|
|
memberID, "bcast-member@hlc.com",
|
|
otherSenderID, "bcast-admin@hlc.com",
|
|
); err != nil {
|
|
t.Fatalf("seed users: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.email_broadcasts WHERE sender_id = ANY($1)`,
|
|
[]string{leadID.String(), otherSenderID.String()})
|
|
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
|
|
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
|
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = ANY($1)`,
|
|
[]string{leadID.String(), memberID.String(), otherSenderID.String()})
|
|
})
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects (id, type, path, title, status, created_by)
|
|
VALUES ($1, 'project', $1::text, 'Bcast Project', 'active', $2)`,
|
|
projectID, leadID,
|
|
); err != nil {
|
|
t.Fatalf("seed project: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
|
|
VALUES ($1, $2, 'lead', false, $2),
|
|
($1, $3, 'associate', false, $2)`,
|
|
projectID, leadID, memberID,
|
|
); err != nil {
|
|
t.Fatalf("seed team: %v", err)
|
|
}
|
|
|
|
users := NewUserService(pool)
|
|
projectSvc := NewProjectService(pool, users)
|
|
teamSvc := NewTeamService(pool, projectSvc)
|
|
mailSvc, err := NewMailService()
|
|
if err != nil {
|
|
t.Fatalf("mail svc: %v", err)
|
|
}
|
|
tplSvc := NewEmailTemplateService(pool)
|
|
mailSvc.SetTemplateService(tplSvc)
|
|
bcast := NewBroadcastService(pool, mailSvc, users, teamSvc, tplSvc)
|
|
|
|
// --- 1. lead can send a broadcast on their project --------------
|
|
pid := projectID
|
|
report, err := bcast.Send(ctx, leadID, BroadcastInput{
|
|
ProjectID: &pid,
|
|
Subject: "Hallo Team",
|
|
Body: "Hi {{first_name}}, kurze Nachricht.",
|
|
Recipients: []BroadcastRecipient{{
|
|
UserID: memberID,
|
|
Email: "bcast-member@hlc.com",
|
|
DisplayName: "Bcast Mem",
|
|
FirstName: "Bcast",
|
|
RoleOnProject: "associate",
|
|
}},
|
|
RecipientFilter: map[string]any{"project_ids": []string{pid.String()}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Send (lead): %v", err)
|
|
}
|
|
if report.BroadcastID == uuid.Nil {
|
|
t.Fatal("BroadcastID empty")
|
|
}
|
|
if report.Total != 1 {
|
|
t.Errorf("Total=%d, want 1", report.Total)
|
|
}
|
|
if report.Sent != 1 || report.Failed != 0 {
|
|
t.Errorf("Sent=%d Failed=%d, want Sent=1 Failed=0", report.Sent, report.Failed)
|
|
}
|
|
|
|
// --- 2. non-lead sender (member) → forbidden --------------------
|
|
_, err = bcast.Send(ctx, memberID, BroadcastInput{
|
|
ProjectID: &pid,
|
|
Subject: "Should fail",
|
|
Body: "x",
|
|
Recipients: []BroadcastRecipient{{
|
|
UserID: leadID, Email: "bcast-lead@hlc.com", DisplayName: "Bcast Lead",
|
|
}},
|
|
})
|
|
if err == nil || !errorIs(err, ErrBroadcastForbidden) {
|
|
t.Errorf("non-lead Send: got %v, want ErrBroadcastForbidden", err)
|
|
}
|
|
|
|
// --- 3. global_admin sees all rows in List ----------------------
|
|
rowsAdmin, err := bcast.List(ctx, otherSenderID, 50)
|
|
if err != nil {
|
|
t.Fatalf("List(admin): %v", err)
|
|
}
|
|
foundOurRow := false
|
|
for _, r := range rowsAdmin {
|
|
if r.ID == report.BroadcastID {
|
|
foundOurRow = true
|
|
if r.RecipientCount != 1 {
|
|
t.Errorf("RecipientCount=%d, want 1", r.RecipientCount)
|
|
}
|
|
}
|
|
}
|
|
if !foundOurRow {
|
|
t.Error("admin's List did not include our broadcast")
|
|
}
|
|
|
|
// --- 4. lead sees own rows --------------------------------------
|
|
rowsLead, err := bcast.List(ctx, leadID, 50)
|
|
if err != nil {
|
|
t.Fatalf("List(lead): %v", err)
|
|
}
|
|
if len(rowsLead) == 0 || rowsLead[0].ID != report.BroadcastID {
|
|
t.Errorf("lead List didn't return own row; got %+v", rowsLead)
|
|
}
|
|
|
|
// --- 5. non-sender, non-admin gets nothing back -----------------
|
|
rowsMember, err := bcast.List(ctx, memberID, 50)
|
|
if err != nil {
|
|
t.Fatalf("List(member): %v", err)
|
|
}
|
|
for _, r := range rowsMember {
|
|
if r.ID == report.BroadcastID {
|
|
t.Errorf("member should not see lead's broadcast %s", r.ID)
|
|
}
|
|
}
|
|
|
|
// --- 6. Get returns full detail w/ recipients -------------------
|
|
detail, err := bcast.Get(ctx, leadID, report.BroadcastID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if detail.Subject != "Hallo Team" {
|
|
t.Errorf("Subject=%q", detail.Subject)
|
|
}
|
|
if len(detail.Recipients) != 1 {
|
|
t.Errorf("Recipients=%d, want 1", len(detail.Recipients))
|
|
}
|
|
if len(detail.Recipients) >= 1 && detail.Recipients[0].UserID != memberID {
|
|
t.Errorf("Recipients[0].UserID=%s, want %s", detail.Recipients[0].UserID, memberID)
|
|
}
|
|
|
|
// --- 7. member calling Get on lead's row → forbidden -----------
|
|
if _, err := bcast.Get(ctx, memberID, report.BroadcastID); err == nil ||
|
|
!errorIs(err, ErrBroadcastForbidden) {
|
|
t.Errorf("member Get: got %v, want ErrBroadcastForbidden", err)
|
|
}
|
|
}
|