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).
This commit is contained in:
m
2026-05-07 20:58:57 +02:00
parent 99f08e3863
commit 52ee319fd8
23 changed files with 2612 additions and 2 deletions

View File

@@ -38,6 +38,7 @@ import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
@@ -264,6 +265,7 @@ async function build() {
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
@@ -379,6 +381,7 @@ async function build() {
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in

View File

@@ -0,0 +1,66 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminBroadcasts(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.broadcasts.title">Broadcasts &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/broadcasts" />
<BottomNav currentPath="/admin/broadcasts" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.broadcasts.heading">Broadcasts</h1>
<p className="tool-subtitle" data-i18n="admin.broadcasts.subtitle">
Versendete Massen-E-Mails an Teamauswahlen.
</p>
</div>
</div>
<div className="entity-table-wrap">
<table className="entity-table entity-table--readonly broadcasts-table">
<thead>
<tr>
<th data-i18n="admin.broadcasts.col.sent_at">Gesendet</th>
<th data-i18n="admin.broadcasts.col.subject">Betreff</th>
<th data-i18n="admin.broadcasts.col.sender">Absender:in</th>
<th data-i18n="admin.broadcasts.col.count">Empf&auml;nger</th>
</tr>
</thead>
<tbody id="broadcasts-tbody">
<tr><td colspan={4} data-i18n="admin.broadcasts.loading">Lade ...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="broadcasts-empty" style="display:none">
<p data-i18n="admin.broadcasts.empty">Noch keine Broadcasts versandt.</p>
</div>
<div id="broadcast-detail" className="hidden" />
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-broadcasts.js"></script>
</body>
</html>
);
}

View File

@@ -83,6 +83,11 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenf&uuml;hren, bef&ouml;rdern.</p>
</a>
<a href="/admin/broadcasts" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>

View File

@@ -0,0 +1,137 @@
// 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();
});

View File

@@ -0,0 +1,283 @@
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
//
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
// collects subject + body + (optional) template and posts to
// /api/team/broadcast. On success it shows a per-recipient send report
// and closes.
//
// Per-recipient privacy: each member receives their own envelope. The
// modal lists every addressee so the sender knows exactly who will be
// mailed; there is no surprise to-line.
import { t } from "./i18n";
export interface BroadcastRecipient {
user_id: string;
email: string;
display_name: string;
first_name: string;
role_on_project: string;
}
export interface OpenBroadcastModalArgs {
recipients: BroadcastRecipient[];
projectID?: string | null;
projectIDs?: string[];
offices?: string[];
roles?: string[];
}
interface EmailTemplateOption {
key: string;
subject: string;
body: string;
is_default: boolean;
}
const RECIPIENT_CAP = 100;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// firstName extracts the first whitespace-separated token from a display
// name. "Anna von Beispiel" → "Anna". Empty input → "".
export function firstName(displayName: string): string {
return displayName.trim().split(/\s+/)[0] ?? "";
}
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
if (!args.recipients.length) {
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
return;
}
if (args.recipients.length > RECIPIENT_CAP) {
alert(
(t("team.broadcast.error.too_many") || "Empfängerlimit ({cap}) überschritten.").replace(
"{cap}",
String(RECIPIENT_CAP),
),
);
return;
}
// Existing modal? Remove. Avoids stacking on rapid double-click.
document.getElementById("broadcast-modal")?.remove();
const overlay = document.createElement("div");
overlay.id = "broadcast-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args);
document.body.appendChild(overlay);
// Close handlers
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener("keydown", function escClose(e) {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escClose);
}
});
// Recipient toggle
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
// Submit
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await onSubmit(form, overlay, args);
});
}
function renderShell(args: OpenBroadcastModalArgs): string {
const count = args.recipients.length;
const previewItems = args.recipients
.slice(0, 5)
.map((r) => esc(r.display_name) + " &lt;" + esc(r.email) + "&gt;")
.join(", ");
const more = count > 5 ? ` +${count - 5}` : "";
const fullList = args.recipients
.map(
(r) =>
`<li><span class="broadcast-recip-name">${esc(r.display_name)}</span> <span class="broadcast-recip-email">&lt;${esc(r.email)}&gt;</span>${
r.role_on_project ? ` <span class="broadcast-recip-role">${esc(r.role_on_project)}</span>` : ""
}</li>`,
)
.join("");
return `
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
<header class="modal-header">
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">&times;</button>
</header>
<form data-broadcast-form>
<div class="modal-body">
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
</footer>
</form>
</div>
`;
}
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
errEl?.classList.add("hidden");
okEl?.classList.add("hidden");
if (!subject) {
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
return;
}
if (!body) {
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
return;
}
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
}
const recipientFilter: Record<string, unknown> = {};
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
if (args.projectID) recipientFilter.project_id = args.projectID;
if (args.offices?.length) recipientFilter.offices = args.offices;
if (args.roles?.length) recipientFilter.roles = args.roles;
const lang = (document.documentElement.lang === "en" ? "en" : "de");
try {
const res = await fetch("/api/team/broadcast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_id: args.projectID ?? null,
subject,
body,
template_key: templateKey || undefined,
lang,
recipient_filter: recipientFilter,
recipients: args.recipients,
}),
});
if (!res.ok) {
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
showError(errEl, (errBody as { error?: string }).error || "Send failed");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
return;
}
const report = (await res.json()) as { sent: number; failed: number; total: number };
if (okEl) {
okEl.classList.remove("hidden");
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
okEl.textContent = tpl
.replace("{sent}", String(report.sent))
.replace("{total}", String(report.total))
.replace("{failed}", String(report.failed));
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
}
setTimeout(() => overlay.remove(), 2500);
} catch (e) {
showError(errEl, String(e));
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
}
}
function showError(el: HTMLDivElement | null | undefined, msg: string) {
if (!el) return;
el.textContent = msg;
el.classList.remove("hidden");
}
// stripGoTemplate is best-effort: existing email templates carry
// `{{define "content"}}` wrappers and Go-template branches the broadcast
// compose form can't honour. The bulk-send pipeline expects plain
// Markdown + the placeholder set documented in the modal, so we strip
// the template directives before populating the textarea. Senders can
// still edit further.
function stripGoTemplate(src: string): string {
return src
.replace(/\{\{\s*(define|end|block|if|else|range|with)\b[^}]*\}\}/g, "")
.trim();
}

View File

@@ -1401,6 +1401,52 @@ const translations: Record<Lang, Record<string, string>> = {
"team.dept.lead": "Lead",
"team.dept.unassigned": "Ohne Partner Unit",
"team.partner_unit.unassigned": "Ohne Partner Unit",
// Project filter (t-paliad-147)
"team.filter.project": "Projekt",
"team.filter.project.all": "Alle Projekte",
"team.filter.project.selected": "ausgewählt",
"team.filter.project.clear": "Alle abwählen",
// Broadcast modal (t-paliad-147)
"team.broadcast.button": "E-Mail an Auswahl",
"team.broadcast.title": "E-Mail an Auswahl",
"team.broadcast.recipients": "Empfänger",
"team.broadcast.show_all": "Alle anzeigen",
"team.broadcast.template": "Vorlage",
"team.broadcast.template_optional": "optional",
"team.broadcast.template_freeform": "Freitext",
"team.broadcast.template.invitation": "Einladung",
"team.broadcast.template.deadline_digest": "Frist-Digest",
"team.broadcast.subject": "Betreff",
"team.broadcast.body": "Nachricht",
"team.broadcast.body_placeholder": "Hallo {{first_name}}, …",
"team.broadcast.placeholders_hint": "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}",
"team.broadcast.markdown_hint": "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.",
"team.broadcast.send": "Senden",
"team.broadcast.sending": "Sende…",
"team.broadcast.sent": "Versandt",
"team.broadcast.success": "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).",
"team.broadcast.error.no_recipients": "Keine Empfänger ausgewählt.",
"team.broadcast.error.too_many": "Empfängerlimit ({cap}) überschritten.",
"team.broadcast.error.subject_required": "Betreff ist erforderlich.",
"team.broadcast.error.body_required": "Nachricht ist erforderlich.",
"common.close": "Schließen",
// Admin broadcasts viewer (t-paliad-147)
"admin.broadcasts.title": "Broadcasts — Paliad",
"admin.broadcasts.heading": "Broadcasts",
"admin.broadcasts.subtitle": "Versendete Massen-E-Mails an Teamauswahlen.",
"admin.broadcasts.col.sent_at": "Gesendet",
"admin.broadcasts.col.subject": "Betreff",
"admin.broadcasts.col.sender": "Absender:in",
"admin.broadcasts.col.count": "Empfänger",
"admin.broadcasts.loading": "Lade…",
"admin.broadcasts.empty": "Noch keine Broadcasts versandt.",
"admin.broadcasts.detail.sent_by": "Gesendet von",
"admin.broadcasts.detail.delivered": "versandt",
"admin.broadcasts.detail.failed": "fehlgeschlagen",
"admin.broadcasts.detail.recipients": "Empfänger",
"common.forbidden": "Zugriff verweigert.",
"common.load_error": "Fehler beim Laden.",
"common.loading": "Lade…",
"partner_unit.heading": "Meine Partner Units",
"partner_unit.subtitle": "Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.",
"partner_unit.none": "Sie sind noch keiner Partner Unit zugeordnet.",
@@ -1426,6 +1472,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.",
"admin.card.feature_flags.title": "Feature-Flags",
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.",
"admin.email_templates.title": "Email-Templates — Paliad",
"admin.email_templates.heading": "Email-Templates",
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
@@ -3208,6 +3256,52 @@ const translations: Record<Lang, Record<string, string>> = {
"team.dept.lead": "Lead",
"team.dept.unassigned": "No partner unit",
"team.partner_unit.unassigned": "No partner unit",
// Project filter (t-paliad-147)
"team.filter.project": "Project",
"team.filter.project.all": "All projects",
"team.filter.project.selected": "selected",
"team.filter.project.clear": "Deselect all",
// Broadcast modal (t-paliad-147)
"team.broadcast.button": "Email selection",
"team.broadcast.title": "Email selection",
"team.broadcast.recipients": "Recipients",
"team.broadcast.show_all": "Show all",
"team.broadcast.template": "Template",
"team.broadcast.template_optional": "optional",
"team.broadcast.template_freeform": "Free-form",
"team.broadcast.template.invitation": "Invitation",
"team.broadcast.template.deadline_digest": "Deadline digest",
"team.broadcast.subject": "Subject",
"team.broadcast.body": "Message",
"team.broadcast.body_placeholder": "Hi {{first_name}}, …",
"team.broadcast.placeholders_hint": "Placeholders: {{name}}, {{first_name}}, {{role_on_project}}",
"team.broadcast.markdown_hint": "Markdown supported: **bold**, *italic*, [link](https://...), - bullet.",
"team.broadcast.send": "Send",
"team.broadcast.sending": "Sending…",
"team.broadcast.sent": "Sent",
"team.broadcast.success": "{sent} of {total} emails sent ({failed} failed).",
"team.broadcast.error.no_recipients": "No recipients selected.",
"team.broadcast.error.too_many": "Recipient limit ({cap}) exceeded.",
"team.broadcast.error.subject_required": "Subject is required.",
"team.broadcast.error.body_required": "Message is required.",
"common.close": "Close",
// Admin broadcasts viewer (t-paliad-147)
"admin.broadcasts.title": "Broadcasts — Paliad",
"admin.broadcasts.heading": "Broadcasts",
"admin.broadcasts.subtitle": "Sent bulk emails to team selections.",
"admin.broadcasts.col.sent_at": "Sent",
"admin.broadcasts.col.subject": "Subject",
"admin.broadcasts.col.sender": "Sender",
"admin.broadcasts.col.count": "Recipients",
"admin.broadcasts.loading": "Loading…",
"admin.broadcasts.empty": "No broadcasts sent yet.",
"admin.broadcasts.detail.sent_by": "Sent by",
"admin.broadcasts.detail.delivered": "delivered",
"admin.broadcasts.detail.failed": "failed",
"admin.broadcasts.detail.recipients": "Recipients",
"common.forbidden": "Access denied.",
"common.load_error": "Load error.",
"common.loading": "Loading…",
"partner_unit.heading": "My Partner Units",
"partner_unit.subtitle": "Partner Units are structural units — separate from project teams. Membership is admin-managed.",
"partner_unit.none": "You are not a member of any Partner Unit yet.",
@@ -3233,6 +3327,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.",
"admin.card.feature_flags.title": "Feature Flags",
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.",
"admin.email_templates.title": "Email Templates — Paliad",
"admin.email_templates.heading": "Email Templates",
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",

View File

@@ -1,5 +1,6 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
interface User {
id: string;
@@ -10,6 +11,25 @@ interface User {
job_title?: string | null;
}
interface MembershipEntry {
user_id: string;
project_ids: string[];
lead_project_ids: string[];
roles: string[];
}
interface ProjectSummary {
id: string;
title: string;
type: string;
reference?: string | null;
}
interface MeUser {
id: string;
global_role: string;
}
interface DepartmentMember {
user_id: string;
email: string;
@@ -48,9 +68,13 @@ const ROLE_ORDER = [
let users: User[] = [];
let departments: Department[] = [];
let memberships: MembershipEntry[] = [];
let projectsList: ProjectSummary[] = [];
let me: MeUser | null = null;
let groupBy: "office" | "department" = "office";
let activeOffice = "all";
let activeRole = "all";
let activeProjectIDs: Set<string> = new Set();
let searchQuery = "";
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
@@ -87,15 +111,26 @@ function initials(name: string): string {
}
async function loadAll() {
const [usersResp, deptsResp] = await Promise.all([
const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/partner-units?include=members"),
fetch("/api/team/memberships"),
fetch("/api/projects"),
fetch("/api/me"),
]);
if (usersResp.ok) users = (await usersResp.json()) as User[];
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[];
if (projectsResp.ok) {
const raw = (await projectsResp.json()) as ProjectSummary[];
projectsList = raw;
}
if (meResp.ok) me = (await meResp.json()) as MeUser;
buildOfficeFilters();
buildRoleFilters();
buildProjectFilter();
render();
updateBroadcastButton();
}
function presentOffices(): string[] {
@@ -191,6 +226,176 @@ function userMatchesRole(u: User): boolean {
return roleKey(u.job_title) === activeRole.toLowerCase();
}
// userMatchesProject returns true when the project filter is empty or
// when the user is a direct member of at least one selected project.
// Inherited memberships intentionally don't qualify here — users want
// "people I can mail on this matter", which means direct membership.
function userMatchesProject(u: User): boolean {
if (activeProjectIDs.size === 0) return true;
const m = memberships.find((m) => m.user_id === u.id);
if (!m) return false;
for (const pid of m.project_ids) {
if (activeProjectIDs.has(pid)) return true;
}
return false;
}
// canBroadcast reports whether the current user is allowed to send a
// broadcast given the active project filter. global_admin always wins.
// Otherwise the user must be a 'lead' on every project they have
// selected (or, when no project is selected, on at least one of their
// own projects).
function canBroadcast(): boolean {
if (!me) return false;
if (me.global_role === "global_admin") return true;
const myMembership = memberships.find((m) => m.user_id === me?.id);
if (!myMembership || !myMembership.lead_project_ids.length) return false;
if (activeProjectIDs.size === 0) {
// No project filter — allow when caller leads at least one project.
// Server-side check still runs per-broadcast so a non-lead can never
// actually send.
return true;
}
for (const pid of activeProjectIDs) {
if (!myMembership.lead_project_ids.includes(pid)) return false;
}
return true;
}
function buildProjectFilter() {
const container = document.getElementById("team-project-filter");
if (!container) return;
// Show only projects the caller can see — projectsList already does
// that via the visibility-gated /api/projects endpoint.
const sortedProjects = [...projectsList].sort((a, b) =>
(a.title || "").localeCompare(b.title || ""),
);
const options = sortedProjects
.map(
(p) =>
`<label class="filter-checkbox"><input type="checkbox" data-project-id="${esc(p.id)}" ${
activeProjectIDs.has(p.id) ? "checked" : ""
} /> <span>${esc(p.title)}</span></label>`,
)
.join("");
const summary = activeProjectIDs.size === 0
? (t("team.filter.project.all") || "Alle Projekte")
: `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`;
container.innerHTML = `
<button type="button" class="filter-pill team-project-trigger" data-project-trigger>
<span class="team-project-summary">${esc(t("team.filter.project") || "Projekt")}: ${esc(summary)}</span>
</button>
<div class="team-project-panel hidden" data-project-panel>
<div class="team-project-actions">
<button type="button" class="link-button" data-project-clear>${esc(t("team.filter.project.clear") || "Alle abwählen")}</button>
</div>
<div class="team-project-options">${options}</div>
</div>
`;
const trigger = container.querySelector<HTMLButtonElement>("[data-project-trigger]");
const panel = container.querySelector<HTMLDivElement>("[data-project-panel]");
trigger?.addEventListener("click", (e) => {
e.stopPropagation();
panel?.classList.toggle("hidden");
});
document.addEventListener("click", (e) => {
if (!container.contains(e.target as Node)) panel?.classList.add("hidden");
});
container.querySelectorAll<HTMLInputElement>("input[data-project-id]").forEach((cb) => {
cb.addEventListener("change", () => {
const pid = cb.dataset.projectId!;
if (cb.checked) activeProjectIDs.add(pid);
else activeProjectIDs.delete(pid);
buildProjectFilter();
render();
updateBroadcastButton();
});
});
container.querySelector<HTMLButtonElement>("[data-project-clear]")?.addEventListener("click", () => {
activeProjectIDs.clear();
buildProjectFilter();
render();
updateBroadcastButton();
});
}
function buildBroadcastButton() {
const wrap = document.getElementById("team-broadcast-wrap");
if (!wrap) return;
if (!canBroadcast()) {
wrap.innerHTML = "";
wrap.style.display = "none";
return;
}
wrap.style.display = "";
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
}
function updateBroadcastButton() {
buildBroadcastButton();
const countEl = document.getElementById("team-broadcast-count");
if (countEl) {
const n = displayedRecipients().length;
countEl.textContent = String(n);
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = n === 0;
}
}
// displayedRecipients returns the currently visible users as broadcast
// recipients. Personal placeholder fields are sourced from each user
// (display_name / first_name) and from the membership index when a
// project filter is set (role_on_project = the role on the selected
// project; falls back to first available role).
function displayedRecipients(): BroadcastRecipient[] {
const filtered = users.filter(
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
);
return filtered.map((u) => {
const m = memberships.find((m) => m.user_id === u.id);
let role = "";
if (m) {
if (activeProjectIDs.size > 0) {
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
if (idx >= 0) role = m.roles[idx];
} else if (m.roles.length > 0) {
role = m.roles[0];
}
}
return {
user_id: u.id,
email: u.email,
display_name: u.display_name,
first_name: firstName(u.display_name),
role_on_project: role,
};
});
}
function onBroadcastClick() {
const recipients = displayedRecipients();
const selectedProjectIDs = Array.from(activeProjectIDs);
// When exactly one project is selected we pass it as project_id so
// the backend can verify lead-ship on that project. With multi-
// select we leave project_id null and rely on global_admin (the
// service rejects non-admin senders without a project_id).
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
const offices = activeOffice === "all" ? [] : [activeOffice];
const roles = activeRole === "all" ? [] : [activeRole];
openBroadcastModal({
recipients,
projectID,
projectIDs: selectedProjectIDs,
offices,
roles,
});
}
function memberAsUser(m: DepartmentMember): User | undefined {
return users.find((u) => u.id === m.user_id);
}
@@ -297,8 +502,11 @@ function render() {
const empty = document.getElementById("team-empty")!;
const count = document.getElementById("team-count")!;
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesSearch(u));
const filtered = users.filter(
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
);
count.textContent = `${filtered.length} / ${users.length}`;
updateBroadcastButton();
if (filtered.length === 0) {
list.innerHTML = "";

View File

@@ -46,8 +46,23 @@ export type I18nKey =
| "admin.audit.source.reminder_log"
| "admin.audit.subtitle"
| "admin.audit.title"
| "admin.broadcasts.col.count"
| "admin.broadcasts.col.sender"
| "admin.broadcasts.col.sent_at"
| "admin.broadcasts.col.subject"
| "admin.broadcasts.detail.delivered"
| "admin.broadcasts.detail.failed"
| "admin.broadcasts.detail.recipients"
| "admin.broadcasts.detail.sent_by"
| "admin.broadcasts.empty"
| "admin.broadcasts.heading"
| "admin.broadcasts.loading"
| "admin.broadcasts.subtitle"
| "admin.broadcasts.title"
| "admin.card.audit.desc"
| "admin.card.audit.title"
| "admin.card.broadcasts.desc"
| "admin.card.broadcasts.title"
| "admin.card.email_templates.desc"
| "admin.card.email_templates.title"
| "admin.card.event_types.desc"
@@ -512,6 +527,10 @@ export type I18nKey =
| "checklisten.tab.templates"
| "checklisten.title"
| "common.cancel"
| "common.close"
| "common.forbidden"
| "common.load_error"
| "common.loading"
| "dashboard.action.short.akte_archived"
| "dashboard.action.short.akte_created"
| "dashboard.action.short.appointment_approval_approved"
@@ -1585,10 +1604,36 @@ export type I18nKey =
| "search.no_results"
| "search.placeholder"
| "sidebar.resize.title"
| "team.broadcast.body"
| "team.broadcast.body_placeholder"
| "team.broadcast.button"
| "team.broadcast.error.body_required"
| "team.broadcast.error.no_recipients"
| "team.broadcast.error.subject_required"
| "team.broadcast.error.too_many"
| "team.broadcast.markdown_hint"
| "team.broadcast.placeholders_hint"
| "team.broadcast.recipients"
| "team.broadcast.send"
| "team.broadcast.sending"
| "team.broadcast.sent"
| "team.broadcast.show_all"
| "team.broadcast.subject"
| "team.broadcast.success"
| "team.broadcast.template"
| "team.broadcast.template.deadline_digest"
| "team.broadcast.template.invitation"
| "team.broadcast.template_freeform"
| "team.broadcast.template_optional"
| "team.broadcast.title"
| "team.dept.lead"
| "team.dept.unassigned"
| "team.empty"
| "team.filter.all"
| "team.filter.project"
| "team.filter.project.all"
| "team.filter.project.clear"
| "team.filter.project.selected"
| "team.filter.role"
| "team.group.department"
| "team.group.office"

View File

@@ -10786,3 +10786,221 @@ dialog.quick-add-sheet::backdrop {
font-style: italic;
}
/* === Bulk team-email broadcast (t-paliad-147) === */
/* Project multi-select filter on /team. */
.team-filter-row-project {
position: relative;
display: inline-flex;
align-items: center;
margin-bottom: 8px;
}
.team-project-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
}
.team-project-summary {
font-weight: 500;
}
.team-project-panel {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
min-width: 280px;
max-width: 420px;
max-height: 360px;
overflow-y: auto;
margin-top: 4px;
padding: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
.team-project-panel.hidden {
display: none;
}
.team-project-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border);
}
.team-project-options {
display: flex;
flex-direction: column;
gap: 6px;
}
.team-project-options .filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.team-project-options .filter-checkbox:hover {
background: var(--color-bg-muted);
}
.team-broadcast-wrap {
margin: 12px 0 0 0;
}
.team-broadcast-count {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
background: rgba(255, 255, 255, 0.25);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */
.modal-broadcast {
width: 720px;
max-width: 92vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-broadcast .modal-body {
overflow-y: auto;
flex: 1;
padding: 16px 20px;
}
.modal-broadcast label {
display: block;
margin-top: 12px;
margin-bottom: 4px;
font-weight: 500;
font-size: 14px;
}
.modal-broadcast input[type="text"],
.modal-broadcast textarea,
.modal-broadcast select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-family: inherit;
font-size: 14px;
}
.modal-broadcast textarea {
resize: vertical;
min-height: 200px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
}
.broadcast-recipient-summary {
padding: 10px 12px;
background: var(--color-bg-muted);
border-radius: 4px;
font-size: 13px;
}
.broadcast-recipient-preview {
margin-top: 4px;
color: var(--color-text-muted);
}
.broadcast-recipient-list {
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 8px 12px;
}
.broadcast-recipient-list.hidden {
display: none;
}
.broadcast-recipient-list ul {
margin: 0;
padding-left: 18px;
}
.broadcast-recipient-list li {
margin: 4px 0;
font-size: 13px;
}
.broadcast-recip-email {
color: var(--color-text-muted);
font-size: 12px;
}
.broadcast-recip-role {
margin-left: 6px;
padding: 0 6px;
background: var(--color-bg-muted);
border-radius: 3px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.broadcast-hint {
margin-top: 6px;
font-size: 12px;
color: var(--color-text-muted);
}
.broadcast-error {
margin-top: 12px;
padding: 8px 10px;
background: rgba(220, 38, 38, 0.08);
color: rgb(185, 28, 28);
border-radius: 4px;
font-size: 13px;
}
.broadcast-error.hidden,
.broadcast-success.hidden {
display: none;
}
.broadcast-success {
margin-top: 12px;
padding: 8px 10px;
background: rgba(34, 197, 94, 0.1);
color: rgb(21, 128, 61);
border-radius: 4px;
font-size: 13px;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--color-link, #2563eb);
cursor: pointer;
text-decoration: underline;
font-size: inherit;
}
/* /admin/broadcasts viewer */
.broadcasts-table td {
vertical-align: top;
padding: 10px 12px;
}
.broadcast-detail-body {
margin-top: 12px;
padding: 12px 16px;
background: var(--color-bg-muted);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
white-space: pre-wrap;
}
.broadcast-detail-recipients ul {
margin: 8px 0;
padding-left: 18px;
columns: 2;
}
.broadcast-detail-recipients li {
break-inside: avoid;
font-size: 13px;
margin: 2px 0;
}
@media (max-width: 640px) {
.broadcast-detail-recipients ul {
columns: 1;
}
}

View File

@@ -68,6 +68,12 @@ export function renderTeam(): string {
<button className="filter-pill active" data-role="all" type="button" data-i18n="team.filter.all">Alle</button>
</div>
<div className="team-filter-row team-filter-row-project" id="team-project-filter" aria-label="Projekt">
</div>
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
</div>
<div className="team-list" id="team-list" />
<div className="glossar-empty" id="team-empty" style="display:none">