Files
paliad/frontend/src/client/admin-broadcasts.ts
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

138 lines
4.5 KiB
TypeScript

// admin-broadcasts.ts — read-only viewer for paliad.email_broadcasts.
//
// global_admin sees every row; senders see only their own. Authority is
// enforced server-side; this client just renders whatever /api/admin/broadcasts
// returns. Click a row → load detail (subject, body, recipient list).
import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface BroadcastRow {
id: string;
subject: string;
sender_id: string;
sender_name: string;
sender_email: string;
recipient_count: number;
sent_at: string;
template_key?: string;
}
interface BroadcastDetailRecipient {
id: string;
email: string;
display_name: string;
}
interface BroadcastDetail extends BroadcastRow {
body: string;
recipient_filter: Record<string, unknown>;
send_report: { total: number; sent: number; failed: number };
recipients: BroadcastDetailRecipient[];
}
let rows: BroadcastRow[] = [];
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
async function load(): Promise<void> {
const tbody = document.getElementById("broadcasts-tbody")!;
const empty = document.getElementById("broadcasts-empty")!;
try {
const res = await fetch("/api/admin/broadcasts");
if (!res.ok) {
if (res.status === 403) {
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.forbidden") || "Zugriff verweigert.")}</td></tr>`;
return;
}
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
return;
}
rows = (await res.json()) as BroadcastRow[];
} catch {
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
return;
}
if (!rows.length) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = rows
.map(
(r) => `
<tr data-broadcast-id="${esc(r.id)}">
<td>${esc(fmtDate(r.sent_at))}</td>
<td>${esc(r.subject)}</td>
<td>${esc(r.sender_name || r.sender_email || "—")}</td>
<td>${r.recipient_count}</td>
</tr>
`,
)
.join("");
tbody.querySelectorAll<HTMLTableRowElement>("tr[data-broadcast-id]").forEach((tr) => {
tr.addEventListener("click", () => loadDetail(tr.dataset.broadcastId!));
tr.style.cursor = "pointer";
});
}
async function loadDetail(id: string): Promise<void> {
const detail = document.getElementById("broadcast-detail")!;
detail.classList.remove("hidden");
detail.innerHTML = `<p>${esc(t("common.loading") || "Lade…")}</p>`;
try {
const res = await fetch(`/api/admin/broadcasts/${encodeURIComponent(id)}`);
if (!res.ok) {
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
return;
}
const d = (await res.json()) as BroadcastDetail;
const recList = (d.recipients || [])
.map(
(r) =>
`<li>${esc(r.display_name || "—")} <span class="broadcast-recip-email">&lt;${esc(r.email)}&gt;</span></li>`,
)
.join("");
const report = d.send_report || { total: d.recipient_count, sent: d.recipient_count, failed: 0 };
detail.innerHTML = `
<article class="card broadcast-detail-card">
<header>
<h2>${esc(d.subject)}</h2>
<p class="muted">
${esc(t("admin.broadcasts.detail.sent_by") || "Gesendet von")} <strong>${esc(d.sender_name || d.sender_email)}</strong>
${esc(fmtDate(d.sent_at))}
${report.sent}/${report.total} ${esc(t("admin.broadcasts.detail.delivered") || "versandt")}
${report.failed > 0 ? `${report.failed} ${esc(t("admin.broadcasts.detail.failed") || "fehlgeschlagen")}` : ""}
</p>
</header>
<div class="broadcast-detail-body">${esc(d.body)}</div>
<section class="broadcast-detail-recipients">
<h3>${esc(t("admin.broadcasts.detail.recipients") || "Empfänger")} (${d.recipients?.length ?? 0})</h3>
<ul>${recList}</ul>
</section>
</article>
`;
detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch {
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(() => load());
load();
});