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).
138 lines
4.5 KiB
TypeScript
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"><${esc(r.email)}></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();
|
|
});
|