merge: main into mai/noether/inventor-paliadin-in-app — pick up fritz's 057_email_broadcasts before adding 058_paliadin_poc
This commit is contained in:
@@ -160,6 +160,7 @@ func main() {
|
||||
Approval: services.NewApprovalService(pool, users),
|
||||
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
|
||||
@@ -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
|
||||
|
||||
66
frontend/src/admin-broadcasts.tsx
Normal file
66
frontend/src/admin-broadcasts.tsx
Normal 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 — 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ä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>
|
||||
);
|
||||
}
|
||||
@@ -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ühren, befö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>
|
||||
|
||||
137
frontend/src/client/admin-broadcasts.ts
Normal file
137
frontend/src/client/admin-broadcasts.ts
Normal 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"><${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();
|
||||
});
|
||||
283
frontend/src/client/broadcast.ts
Normal file
283
frontend/src/client/broadcast.ts
Normal 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) + " <" + esc(r.email) + ">")
|
||||
.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"><${esc(r.email)}></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")}">×</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();
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10553,7 +10553,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar-views-group .sidebar-views-new {
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.sidebar-views-group .sidebar-user-view-item {
|
||||
@@ -10581,9 +10581,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
.views-onboarding {
|
||||
margin: 24px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-subtle, rgba(0,0,0,0.02));
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-onboarding-actions {
|
||||
margin-top: 12px;
|
||||
@@ -10594,10 +10595,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
background: #fff8db;
|
||||
border: 1px solid #f3d27a;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 6px;
|
||||
color: #5b4304;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-toast-close {
|
||||
background: transparent;
|
||||
@@ -10629,21 +10630,22 @@ dialog.quick-add-sheet::backdrop {
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-list-row:hover {
|
||||
background: var(--surface-hover, rgba(0,0,0,0.03));
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
.views-list-time {
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.views-list-kind {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-list-title {
|
||||
font-weight: 500;
|
||||
@@ -10651,11 +10653,11 @@ dialog.quick-add-sheet::backdrop {
|
||||
.views-list-project,
|
||||
.views-list-actor {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-list-subtitle {
|
||||
grid-column: 3 / -1;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -10667,7 +10669,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.views-cards-list {
|
||||
@@ -10680,9 +10682,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
.views-card {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-card-head {
|
||||
display: flex;
|
||||
@@ -10693,7 +10696,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-title {
|
||||
font-size: 16px;
|
||||
@@ -10705,17 +10708,17 @@ dialog.quick-add-sheet::backdrop {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-meta > * + *::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* shape=calendar. */
|
||||
@@ -10733,7 +10736,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
}
|
||||
.views-calendar-grid {
|
||||
@@ -10744,17 +10747,18 @@ dialog.quick-add-sheet::backdrop {
|
||||
.views-calendar-cell {
|
||||
min-height: 80px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-calendar-cell--out {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-subtle, rgba(0,0,0,0.04));
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
.views-calendar-cell-day {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.views-calendar-pills {
|
||||
@@ -10769,20 +10773,239 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-subtle, rgba(0,0,0,0.04));
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.views-calendar-pill--more {
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
}
|
||||
.views-calendar-mobile-notice {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
3
internal/db/migrations/057_email_broadcasts.down.sql
Normal file
3
internal/db/migrations/057_email_broadcasts.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 057_email_broadcasts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.email_broadcasts;
|
||||
91
internal/db/migrations/057_email_broadcasts.up.sql
Normal file
91
internal/db/migrations/057_email_broadcasts.up.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- t-paliad-147: Bulk team email — paliad.email_broadcasts.
|
||||
--
|
||||
-- Records every bulk-send sent from /team's "E-Mail an Auswahl" flow.
|
||||
-- Powers the /admin/broadcasts viewer (global_admin sees all rows;
|
||||
-- senders see their own).
|
||||
--
|
||||
-- recipient_filter snapshots the filter chips the sender had selected
|
||||
-- (project_ids, offices, roles) so a future deploy that tweaks the
|
||||
-- filter UX can still render past sends. recipient_user_ids snapshots
|
||||
-- the resolved user list — the actual addressees, immune to later
|
||||
-- team-membership changes.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE paliad.email_broadcasts.
|
||||
-- 2. Indexes.
|
||||
-- 3. RLS.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.email_broadcasts
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.email_broadcasts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Renderable subject (post-template). Stored verbatim for audit.
|
||||
subject text NOT NULL,
|
||||
|
||||
-- Body source as the sender typed it (Markdown). NOT the per-recipient
|
||||
-- rendered output — those are reconstructable by re-rendering with the
|
||||
-- snapshotted recipient row, but the source is what we audit.
|
||||
body text NOT NULL,
|
||||
|
||||
-- The sender. FK to paliad.users (not auth.users) so deleting an auth
|
||||
-- row leaves the audit trail intact via paliad.users.
|
||||
sender_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
|
||||
-- Optional template the sender started from. NULL when freeform.
|
||||
template_key text,
|
||||
|
||||
-- Snapshot of filter chips selected at send time. Keys: project_ids
|
||||
-- (uuid[]), offices (text[]), roles (text[]). jsonb for forward-compat.
|
||||
recipient_filter jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Resolved addressee list — the user_ids that received (or attempted)
|
||||
-- the mail. Immune to subsequent team-membership changes.
|
||||
recipient_user_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
|
||||
-- Per-send result counts (sent, failed, total). jsonb so we can grow
|
||||
-- the report shape without a migration.
|
||||
send_report jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX email_broadcasts_sent_at_idx
|
||||
ON paliad.email_broadcasts (sent_at DESC);
|
||||
|
||||
CREATE INDEX email_broadcasts_sender_idx
|
||||
ON paliad.email_broadcasts (sender_id, sent_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.email_broadcasts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Senders can read their own rows; global_admin can read everything.
|
||||
-- The Go service layer (BroadcastService) is the load-bearing gate; RLS
|
||||
-- here is defence-in-depth for any future auth-context query path.
|
||||
CREATE POLICY email_broadcasts_select
|
||||
ON paliad.email_broadcasts FOR SELECT
|
||||
USING (
|
||||
sender_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Inserts only by the sender themselves (defence-in-depth — the service
|
||||
-- enforces project_lead-OR-global_admin authorship; RLS only enforces the
|
||||
-- self-attribution bit).
|
||||
CREATE POLICY email_broadcasts_insert
|
||||
ON paliad.email_broadcasts FOR INSERT
|
||||
WITH CHECK (sender_id = auth.uid());
|
||||
197
internal/handlers/broadcasts.go
Normal file
197
internal/handlers/broadcasts.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type Services struct {
|
||||
Approval *services.ApprovalService
|
||||
Derivation *services.DerivationService
|
||||
UserView *services.UserViewService
|
||||
Broadcast *services.BroadcastService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
@@ -102,6 +103,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
approval: svc.Approval,
|
||||
derivation: svc.Derivation,
|
||||
userView: svc.UserView,
|
||||
broadcast: svc.Broadcast,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +343,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// Team directory — browsable list of all onboarded users (t-paliad-029).
|
||||
protected.HandleFunc("GET /team", gateOnboarded(handleTeamPage))
|
||||
|
||||
// t-paliad-147 — bulk team-email broadcast.
|
||||
// /api/team/broadcast: project lead OR global_admin → BroadcastService gates.
|
||||
// /admin/broadcasts page + list/detail API: visibility-gated in service
|
||||
// (global_admin sees all; sender sees own).
|
||||
protected.HandleFunc("GET /api/team/memberships", gateOnboarded(handleListMembershipsIndex))
|
||||
protected.HandleFunc("POST /api/team/broadcast", gateOnboarded(handleTeamBroadcast))
|
||||
protected.HandleFunc("GET /admin/broadcasts", gateOnboarded(handleAdminBroadcastsPage))
|
||||
protected.HandleFunc("GET /api/admin/broadcasts", gateOnboarded(handleListBroadcasts))
|
||||
protected.HandleFunc("GET /api/admin/broadcasts/{id}", gateOnboarded(handleGetBroadcast))
|
||||
|
||||
// Settings
|
||||
protected.HandleFunc("GET /settings", gateOnboarded(handleSettingsPage))
|
||||
protected.HandleFunc("GET /settings/{tab}", handleSettingsTabRedirect)
|
||||
|
||||
@@ -45,6 +45,7 @@ type dbServices struct {
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
@@ -63,6 +63,26 @@ func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
// GET /api/team/memberships — bulk index of project_teams membership for
|
||||
// every (visible) user × project pair. Powers the /team page project-
|
||||
// multi-select filter (t-paliad-147 / issue #7). Cheap to call: one
|
||||
// scan per call; client-side filter handles everything from there.
|
||||
func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.team.ListMembershipsIndex(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
|
||||
// Inherited memberships can't be removed at the child level.
|
||||
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
587
internal/services/broadcast_service.go
Normal file
587
internal/services/broadcast_service.go
Normal file
@@ -0,0 +1,587 @@
|
||||
// Package services — BroadcastService — bulk team-email send.
|
||||
//
|
||||
// Backs the /team page "E-Mail an Auswahl" flow (t-paliad-147 / issue #7).
|
||||
// Each call:
|
||||
//
|
||||
// 1. Validates the sender's authority (project lead OR global_admin)
|
||||
// and the recipient cap.
|
||||
// 2. Renders the per-recipient body (Markdown → HTML, with
|
||||
// {{name}} / {{first_name}} / {{role_on_project}} placeholder
|
||||
// substitution) inside the standard email base wrapper.
|
||||
// 3. Dispatches via MailService.Send with Reply-To set to the
|
||||
// sender's address — From: stays on the SMTP infra address so
|
||||
// DKIM/SPF still hold. Replies route back to the human.
|
||||
// 4. Persists a paliad.email_broadcasts row capturing subject,
|
||||
// body, sender, filter snapshot, and per-recipient send report.
|
||||
//
|
||||
// Per-recipient privacy: each recipient gets their own envelope. We
|
||||
// never put more than one address on the To: header. Recipients can't
|
||||
// see each other.
|
||||
//
|
||||
// Concurrency: a fixed 5-deep goroutine pool dispatches sends with a
|
||||
// per-send timeout. SMTP failures are logged into the report and the
|
||||
// batch continues — one bad address never blocks the rest.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// BroadcastRecipientCap is the soft maximum number of recipients per
|
||||
// broadcast. m-locked at 100 (2026-05-07) — admin-tweakable later if
|
||||
// HLC's regular use case grows.
|
||||
const BroadcastRecipientCap = 100
|
||||
|
||||
// BroadcastSendConcurrency caps the number of in-flight SMTP
|
||||
// connections during a single broadcast. Five is generous enough to
|
||||
// finish a 100-recipient batch in a few seconds while leaving headroom
|
||||
// for the reminder job's own SMTP usage.
|
||||
const BroadcastSendConcurrency = 5
|
||||
|
||||
// BroadcastSendTimeout bounds a single per-recipient SMTP delivery.
|
||||
// Hostinger's submission endpoint typically returns within a second;
|
||||
// 15s gives plenty of slack for transient slowness without holding the
|
||||
// HTTP request open indefinitely.
|
||||
const BroadcastSendTimeout = 15 * time.Second
|
||||
|
||||
// Sentinel errors. Handlers map these to HTTP status codes.
|
||||
var (
|
||||
ErrBroadcastForbidden = errors.New("broadcast: caller is neither project lead nor global_admin")
|
||||
ErrBroadcastNoRecipients = errors.New("broadcast: empty recipient list")
|
||||
ErrBroadcastTooManyRecipients = errors.New("broadcast: recipient cap exceeded")
|
||||
ErrBroadcastEmptySubject = errors.New("broadcast: empty subject")
|
||||
ErrBroadcastEmptyBody = errors.New("broadcast: empty body")
|
||||
ErrBroadcastInvalidEmail = errors.New("broadcast: invalid recipient email")
|
||||
)
|
||||
|
||||
// BroadcastService wires the bulk-send flow.
|
||||
type BroadcastService struct {
|
||||
db *sqlx.DB
|
||||
mail *MailService
|
||||
users *UserService
|
||||
team *TeamService
|
||||
templates *EmailTemplateService
|
||||
|
||||
// clock isolates time.Now for tests.
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewBroadcastService wires the service. mail/users/team/templates
|
||||
// must all be non-nil — the service is only constructed in the DB-backed
|
||||
// path.
|
||||
func NewBroadcastService(db *sqlx.DB, mail *MailService, users *UserService, team *TeamService, templates *EmailTemplateService) *BroadcastService {
|
||||
return &BroadcastService{
|
||||
db: db,
|
||||
mail: mail,
|
||||
users: users,
|
||||
team: team,
|
||||
templates: templates,
|
||||
clock: func() time.Time { return time.Now() },
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastRecipient is one row in the resolved addressee list. Name
|
||||
// values are the per-recipient placeholder substitutions surfaced in
|
||||
// the body.
|
||||
type BroadcastRecipient struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
DisplayName string
|
||||
FirstName string
|
||||
RoleOnProject string
|
||||
}
|
||||
|
||||
// BroadcastInput is what a handler hands to Send.
|
||||
type BroadcastInput struct {
|
||||
// ProjectID identifies the project the broadcast is scoped to. The
|
||||
// caller must be a 'lead' on this project (or a global_admin) for
|
||||
// the send to proceed. nil/zero means "no specific project" —
|
||||
// only global_admin may send in that case.
|
||||
ProjectID *uuid.UUID
|
||||
|
||||
Subject string
|
||||
// Body is the Markdown source the sender typed. Per-recipient
|
||||
// placeholders ({{name}}, {{first_name}}, {{role_on_project}})
|
||||
// are substituted before Markdown rendering.
|
||||
Body string
|
||||
|
||||
// TemplateKey is optional — when set, the broadcast is recorded as
|
||||
// having started from a template, but Subject/Body are still the
|
||||
// authoritative source (we don't re-fetch from the template at
|
||||
// send time).
|
||||
TemplateKey string
|
||||
|
||||
// RecipientFilter is the snapshot of filter chips the sender had
|
||||
// selected. Persisted into email_broadcasts.recipient_filter for
|
||||
// future audit.
|
||||
RecipientFilter map[string]any
|
||||
|
||||
Recipients []BroadcastRecipient
|
||||
|
||||
// Lang controls the wrapper template language. Defaults to "de".
|
||||
Lang string
|
||||
}
|
||||
|
||||
// BroadcastReport summarises a send.
|
||||
type BroadcastReport struct {
|
||||
BroadcastID uuid.UUID `json:"broadcast_id"`
|
||||
Total int `json:"total"`
|
||||
Sent int `json:"sent"`
|
||||
Failed int `json:"failed"`
|
||||
Errors map[string]string `json:"errors,omitempty"` // user_id → error
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
}
|
||||
|
||||
// Send dispatches a broadcast. Returns the persisted ID and a per-send
|
||||
// report. The full pipeline runs even when MailService is disabled —
|
||||
// the audit row still lands so deploys without SMTP can be exercised.
|
||||
func (s *BroadcastService) Send(ctx context.Context, callerID uuid.UUID, in BroadcastInput) (*BroadcastReport, error) {
|
||||
// --- Validation (cheap checks first) ----------------------------
|
||||
subject := strings.TrimSpace(in.Subject)
|
||||
if subject == "" {
|
||||
return nil, ErrBroadcastEmptySubject
|
||||
}
|
||||
body := strings.TrimSpace(in.Body)
|
||||
if body == "" {
|
||||
return nil, ErrBroadcastEmptyBody
|
||||
}
|
||||
if len(in.Recipients) == 0 {
|
||||
return nil, ErrBroadcastNoRecipients
|
||||
}
|
||||
if len(in.Recipients) > BroadcastRecipientCap {
|
||||
return nil, fmt.Errorf("%w: %d > %d", ErrBroadcastTooManyRecipients, len(in.Recipients), BroadcastRecipientCap)
|
||||
}
|
||||
for _, r := range in.Recipients {
|
||||
if _, err := mail.ParseAddress(r.Email); err != nil {
|
||||
return nil, fmt.Errorf("%w: %q", ErrBroadcastInvalidEmail, r.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Authorisation ---------------------------------------------
|
||||
sender, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sender: %w", err)
|
||||
}
|
||||
if sender == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
if err := s.assertCanBroadcast(ctx, sender, in.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Persist audit row ahead of send so a partial-batch crash
|
||||
// still leaves a record of intent. send_report is filled in
|
||||
// post-dispatch via UPDATE.
|
||||
lang := in.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
broadcastID := uuid.New()
|
||||
recipientIDs := make([]uuid.UUID, 0, len(in.Recipients))
|
||||
for _, r := range in.Recipients {
|
||||
recipientIDs = append(recipientIDs, r.UserID)
|
||||
}
|
||||
filterJSON, err := json.Marshal(filterMapOrEmpty(in.RecipientFilter))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal filter: %w", err)
|
||||
}
|
||||
|
||||
templateKey := strings.TrimSpace(in.TemplateKey)
|
||||
var templateKeyArg any
|
||||
if templateKey != "" {
|
||||
templateKeyArg = templateKey
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.email_broadcasts
|
||||
(id, subject, body, sender_id, template_key, recipient_filter, recipient_user_ids, send_report, sent_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, '{}'::jsonb, now())`,
|
||||
broadcastID, subject, body, callerID, templateKeyArg, string(filterJSON), pq.Array(recipientIDs),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert broadcast: %w", err)
|
||||
}
|
||||
|
||||
// --- Dispatch -------------------------------------------------
|
||||
report, sendErr := s.dispatch(ctx, *sender, broadcastID, subject, body, lang, in.Recipients)
|
||||
report.BroadcastID = broadcastID
|
||||
|
||||
// Persist the report regardless of dispatch outcome; surface the
|
||||
// dispatch error to the caller so the UI can show a partial-success
|
||||
// toast.
|
||||
reportJSON, marshalErr := json.Marshal(report)
|
||||
if marshalErr != nil {
|
||||
// Truly unexpected — fall back to an empty report shape rather
|
||||
// than wedging the audit row.
|
||||
slog.Error("broadcast: marshal report failed", "broadcast_id", broadcastID, "error", marshalErr)
|
||||
reportJSON = []byte(`{}`)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.email_broadcasts SET send_report = $1::jsonb WHERE id = $2`,
|
||||
string(reportJSON), broadcastID,
|
||||
); err != nil {
|
||||
slog.Error("broadcast: persist report failed", "broadcast_id", broadcastID, "error", err)
|
||||
}
|
||||
|
||||
if sendErr != nil {
|
||||
return report, sendErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// assertCanBroadcast enforces project_lead-OR-global_admin. global_admin
|
||||
// always wins; otherwise the sender must have role='lead' on
|
||||
// in.ProjectID.
|
||||
func (s *BroadcastService) assertCanBroadcast(ctx context.Context, sender *models.User, projectID *uuid.UUID) error {
|
||||
if sender.GlobalRole == "global_admin" {
|
||||
return nil
|
||||
}
|
||||
if projectID == nil {
|
||||
return ErrBroadcastForbidden
|
||||
}
|
||||
var count int
|
||||
if err := s.db.GetContext(ctx, &count,
|
||||
`SELECT COUNT(*) FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2 AND role = 'lead'`,
|
||||
*projectID, sender.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("check lead role: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrBroadcastForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch fans out the per-recipient sends through a bounded pool and
|
||||
// collects the report.
|
||||
func (s *BroadcastService) dispatch(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, recipients []BroadcastRecipient) (*BroadcastReport, error) {
|
||||
type result struct {
|
||||
userID uuid.UUID
|
||||
err error
|
||||
}
|
||||
results := make(chan result, len(recipients))
|
||||
|
||||
sem := make(chan struct{}, BroadcastSendConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, r := range recipients {
|
||||
wg.Add(1)
|
||||
go func(rec BroadcastRecipient) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
sendCtx, cancel := context.WithTimeout(ctx, BroadcastSendTimeout)
|
||||
defer cancel()
|
||||
err := s.sendOne(sendCtx, sender, broadcastID, subject, body, lang, rec)
|
||||
results <- result{userID: rec.UserID, err: err}
|
||||
}(r)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
report := &BroadcastReport{
|
||||
Total: len(recipients),
|
||||
Errors: map[string]string{},
|
||||
SentAt: s.clock(),
|
||||
}
|
||||
for res := range results {
|
||||
if res.err != nil {
|
||||
report.Failed++
|
||||
report.Errors[res.userID.String()] = res.err.Error()
|
||||
slog.Warn("broadcast: send failed",
|
||||
"broadcast_id", broadcastID, "user_id", res.userID, "error", res.err)
|
||||
} else {
|
||||
report.Sent++
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// sendOne renders one personalised email and dispatches it. The
|
||||
// MailService no-ops cleanly when disabled — that path still treats
|
||||
// the recipient as "sent" for the purposes of the report so dev
|
||||
// deploys aren't littered with phantom failures.
|
||||
func (s *BroadcastService) sendOne(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, rec BroadcastRecipient) error {
|
||||
// Subject can carry placeholders too ("Hallo {{first_name}}, …").
|
||||
rendered := substitutePlaceholders(subject, rec)
|
||||
personalisedBody := substitutePlaceholders(body, rec)
|
||||
htmlBody, err := s.renderBroadcastBody(ctx, lang, personalisedBody, sender)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render body: %w", err)
|
||||
}
|
||||
textBody := htmlToText(htmlBody)
|
||||
|
||||
// Custom envelope — we want Reply-To: sender so replies route to the
|
||||
// human who composed the broadcast.
|
||||
if !s.mail.Enabled() {
|
||||
slog.Debug("broadcast: SendOne skipped (mail disabled)",
|
||||
"broadcast_id", broadcastID, "to", rec.Email)
|
||||
return nil
|
||||
}
|
||||
msg := buildMIMEWithReplyTo(s.mail.cfg.From, s.mail.cfg.FromName, sender.Email,
|
||||
rec.Email, rendered, htmlBody, textBody)
|
||||
deliverDone := make(chan error, 1)
|
||||
go func() {
|
||||
deliverDone <- s.mail.deliver(rec.Email, msg)
|
||||
}()
|
||||
select {
|
||||
case err := <-deliverDone:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// renderBroadcastBody wraps the personalised Markdown body in the
|
||||
// standard base.html (DB override or embedded fallback) so broadcast
|
||||
// emails look like the rest of Paliad's mail.
|
||||
func (s *BroadcastService) renderBroadcastBody(ctx context.Context, lang, markdownBody string, sender models.User) (string, error) {
|
||||
htmlContent := renderMarkdownSafe(markdownBody)
|
||||
signature := senderSignature(lang, sender)
|
||||
|
||||
// Build the {{define "content"}} block expected by base.html. The
|
||||
// inner HTML is treated as trusted output (we generated it from
|
||||
// known-safe Markdown rules). Senders can't sneak script tags
|
||||
// because renderMarkdownSafe escapes everything before re-introducing
|
||||
// the whitelisted markup.
|
||||
contentBlock := fmt.Sprintf(`{{define "content"}}%s%s{{end}}`, htmlContent, signature)
|
||||
|
||||
// Look up base.html (key='base'). Same fallback discipline as
|
||||
// MailService.RenderTemplate — if the active row is malformed we
|
||||
// retry with the embedded default.
|
||||
var (
|
||||
baseBody string
|
||||
err error
|
||||
)
|
||||
if s.templates != nil {
|
||||
row, lookupErr := s.templates.GetActive(ctx, EmailTemplateKeyBase, lang)
|
||||
if lookupErr != nil {
|
||||
return "", fmt.Errorf("lookup base template: %w", lookupErr)
|
||||
}
|
||||
baseBody = row.Body
|
||||
} else {
|
||||
baseBody, err = readEmbeddedBody(EmailTemplateKeyBase, lang)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read embedded base: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"Lang": lang,
|
||||
"Firm": branding.Name,
|
||||
"Subject": "", // base.html title field; we don't need it here.
|
||||
}
|
||||
|
||||
html, err := renderBaseAndContent(baseBody, contentBlock, payload)
|
||||
if err == nil {
|
||||
return html, nil
|
||||
}
|
||||
// Active row malformed — fall back to embedded.
|
||||
slog.Error("broadcast: base render failed, falling back to embedded",
|
||||
"lang", lang, "error", err)
|
||||
fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
|
||||
if fbErr != nil {
|
||||
return "", fmt.Errorf("fallback base: %w", fbErr)
|
||||
}
|
||||
return renderBaseAndContent(fbBase, contentBlock, payload)
|
||||
}
|
||||
|
||||
// substitutePlaceholders replaces {{name}}, {{first_name}}, and
|
||||
// {{role_on_project}} with the per-recipient values. Whitespace
|
||||
// inside the braces is tolerated. Unknown {{...}} tokens pass through
|
||||
// untouched so a sender's accidental "literal {{example}}" stays
|
||||
// readable in the rendered mail.
|
||||
func substitutePlaceholders(src string, rec BroadcastRecipient) string {
|
||||
repl := strings.NewReplacer(
|
||||
"{{name}}", rec.DisplayName,
|
||||
"{{ name }}", rec.DisplayName,
|
||||
"{{first_name}}", rec.FirstName,
|
||||
"{{ first_name }}", rec.FirstName,
|
||||
"{{role_on_project}}", rec.RoleOnProject,
|
||||
"{{ role_on_project }}", rec.RoleOnProject,
|
||||
)
|
||||
return repl.Replace(src)
|
||||
}
|
||||
|
||||
// senderSignature appends a "Geschickt von <DisplayName> <email>"
|
||||
// footer below the body so the recipient sees who wrote the mail
|
||||
// even though From: is the SMTP infrastructure address.
|
||||
func senderSignature(lang string, sender models.User) string {
|
||||
prefix := "Gesendet von"
|
||||
if lang == "en" {
|
||||
prefix = "Sent by"
|
||||
}
|
||||
if sender.DisplayName == "" {
|
||||
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s <a href="mailto:%s">%s</a></p>`,
|
||||
prefix, escapeHTML(sender.Email), escapeHTML(sender.Email))
|
||||
}
|
||||
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s %s <<a href="mailto:%s">%s</a>></p>`,
|
||||
prefix, escapeHTML(sender.DisplayName), escapeHTML(sender.Email), escapeHTML(sender.Email))
|
||||
}
|
||||
|
||||
// filterMapOrEmpty normalises a nil filter map to an empty one for
|
||||
// jsonb persistence.
|
||||
func filterMapOrEmpty(in map[string]any) map[string]any {
|
||||
if in == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// --- broadcast list / get queries ----------------------------------
|
||||
|
||||
// BroadcastListEntry is one row on the /admin/broadcasts list.
|
||||
type BroadcastListEntry struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Subject string `db:"subject" json:"subject"`
|
||||
SenderID uuid.UUID `db:"sender_id" json:"sender_id"`
|
||||
SenderName string `db:"sender_name" json:"sender_name"`
|
||||
SenderEmail string `db:"sender_email" json:"sender_email"`
|
||||
RecipientCount int `db:"recipient_count" json:"recipient_count"`
|
||||
SentAt time.Time `db:"sent_at" json:"sent_at"`
|
||||
TemplateKey *string `db:"template_key" json:"template_key,omitempty"`
|
||||
}
|
||||
|
||||
// BroadcastDetail is the per-row detail view.
|
||||
type BroadcastDetail struct {
|
||||
BroadcastListEntry
|
||||
Body string `db:"body" json:"body"`
|
||||
RecipientFilter json.RawMessage `db:"recipient_filter" json:"recipient_filter"`
|
||||
SendReport json.RawMessage `db:"send_report" json:"send_report"`
|
||||
Recipients []BroadcastDetailRecipient `json:"recipients"`
|
||||
}
|
||||
|
||||
// BroadcastDetailRecipient is one resolved addressee on the detail page.
|
||||
// Names are joined from paliad.users at read time so the most recent
|
||||
// display_name shows up; the audit row only retains the user_id.
|
||||
type BroadcastDetailRecipient struct {
|
||||
UserID uuid.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
}
|
||||
|
||||
// List returns broadcasts visible to the caller. global_admin sees
|
||||
// every row; everyone else sees only their own sends.
|
||||
func (s *BroadcastService) List(ctx context.Context, callerID uuid.UUID, limit int) ([]BroadcastListEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
caller, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller: %w", err)
|
||||
}
|
||||
if caller == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
|
||||
var (
|
||||
rows []BroadcastListEntry
|
||||
q string
|
||||
args []any
|
||||
)
|
||||
if caller.GlobalRole == "global_admin" {
|
||||
q = listBroadcastsSQL + ` ORDER BY b.sent_at DESC LIMIT $1`
|
||||
args = []any{limit}
|
||||
} else {
|
||||
q = listBroadcastsSQL + ` WHERE b.sender_id = $1 ORDER BY b.sent_at DESC LIMIT $2`
|
||||
args = []any{callerID, limit}
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list broadcasts: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one broadcast plus its resolved recipient list. Same
|
||||
// visibility rules as List.
|
||||
func (s *BroadcastService) Get(ctx context.Context, callerID, id uuid.UUID) (*BroadcastDetail, error) {
|
||||
caller, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller: %w", err)
|
||||
}
|
||||
if caller == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
var detail BroadcastDetail
|
||||
q := `
|
||||
SELECT b.id, b.subject, b.sender_id, b.template_key,
|
||||
array_length(b.recipient_user_ids, 1) AS recipient_count,
|
||||
b.sent_at, b.body, b.recipient_filter, b.send_report,
|
||||
u.display_name AS sender_name, u.email AS sender_email
|
||||
FROM paliad.email_broadcasts b
|
||||
LEFT JOIN paliad.users u ON u.id = b.sender_id
|
||||
WHERE b.id = $1`
|
||||
if err := s.db.GetContext(ctx, &detail, q, id); err != nil {
|
||||
return nil, fmt.Errorf("get broadcast: %w", err)
|
||||
}
|
||||
if caller.GlobalRole != "global_admin" && detail.SenderID != callerID {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
|
||||
// Resolve recipient names. The audit row stores user_ids only; we
|
||||
// re-join paliad.users at read time so renames flow through. The
|
||||
// uuid[] column comes back as pq.Array; copy it out for sqlx.
|
||||
var idArr pq.StringArray
|
||||
if err := s.db.GetContext(ctx, &idArr,
|
||||
`SELECT recipient_user_ids::text[] FROM paliad.email_broadcasts WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("load recipient ids: %w", err)
|
||||
}
|
||||
recipientIDs := make([]uuid.UUID, 0, len(idArr))
|
||||
for _, s := range idArr {
|
||||
if uid, err := uuid.Parse(s); err == nil {
|
||||
recipientIDs = append(recipientIDs, uid)
|
||||
}
|
||||
}
|
||||
if len(recipientIDs) > 0 {
|
||||
var rec []BroadcastDetailRecipient
|
||||
if err := s.db.SelectContext(ctx, &rec,
|
||||
`SELECT id, email, display_name
|
||||
FROM paliad.users
|
||||
WHERE id = ANY($1)`, pq.Array(recipientIDs),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load recipients: %w", err)
|
||||
}
|
||||
// Preserve the audit-row order — clients want the original
|
||||
// dispatch list, not whatever paliad.users ordered them by.
|
||||
byID := make(map[uuid.UUID]BroadcastDetailRecipient, len(rec))
|
||||
for _, r := range rec {
|
||||
byID[r.UserID] = r
|
||||
}
|
||||
ordered := make([]BroadcastDetailRecipient, 0, len(recipientIDs))
|
||||
for _, uid := range recipientIDs {
|
||||
if r, ok := byID[uid]; ok {
|
||||
ordered = append(ordered, r)
|
||||
continue
|
||||
}
|
||||
// User row was deleted post-broadcast. Show the bare ID so
|
||||
// the audit page still accounts for the slot.
|
||||
ordered = append(ordered, BroadcastDetailRecipient{UserID: uid})
|
||||
}
|
||||
detail.Recipients = ordered
|
||||
}
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
const listBroadcastsSQL = `
|
||||
SELECT b.id, b.subject, b.sender_id, b.template_key,
|
||||
COALESCE(array_length(b.recipient_user_ids, 1), 0) AS recipient_count,
|
||||
b.sent_at,
|
||||
u.display_name AS sender_name, u.email AS sender_email
|
||||
FROM paliad.email_broadcasts b
|
||||
LEFT JOIN paliad.users u ON u.id = b.sender_id
|
||||
`
|
||||
|
||||
191
internal/services/broadcast_service_live_test.go
Normal file
191
internal/services/broadcast_service_live_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
233
internal/services/broadcast_service_test.go
Normal file
233
internal/services/broadcast_service_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestSubstitutePlaceholders(t *testing.T) {
|
||||
rec := BroadcastRecipient{
|
||||
UserID: uuid.New(),
|
||||
Email: "anna@hlc.com",
|
||||
DisplayName: "Anna Beispiel",
|
||||
FirstName: "Anna",
|
||||
RoleOnProject: "lead",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"name", "Hallo {{name}}", "Hallo Anna Beispiel"},
|
||||
{"first_name", "Hi {{first_name}}!", "Hi Anna!"},
|
||||
{"role_on_project", "Du bist {{role_on_project}}.", "Du bist lead."},
|
||||
{"whitespace tolerated", "{{ first_name }}", "Anna"},
|
||||
{"unknown token passes through", "Literal {{example}} stays", "Literal {{example}} stays"},
|
||||
{"all three together",
|
||||
"{{name}} ({{first_name}}, {{role_on_project}})",
|
||||
"Anna Beispiel (Anna, lead)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := substitutePlaceholders(tc.in, rec)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// renderMarkdownSafe must escape raw HTML and only re-emit a small whitelist
|
||||
// of tags. Any leakage of a <script> tag would be an XSS vector since the
|
||||
// rendered output goes straight into an HTML email body.
|
||||
func TestRenderMarkdownSafe(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantContains []string
|
||||
wantMissing []string
|
||||
}{
|
||||
{
|
||||
name: "bold",
|
||||
in: "**hallo**",
|
||||
wantContains: []string{"<strong>hallo</strong>"},
|
||||
},
|
||||
{
|
||||
name: "italic underscore",
|
||||
in: "_hallo_",
|
||||
wantContains: []string{"<em>hallo</em>"},
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
in: "[paliad](https://paliad.de)",
|
||||
wantContains: []string{`<a href="https://paliad.de">paliad</a>`},
|
||||
},
|
||||
{
|
||||
name: "bullet list",
|
||||
in: "- erstens\n- zweitens",
|
||||
wantContains: []string{"<ul>", "<li>erstens</li>", "<li>zweitens</li>", "</ul>"},
|
||||
},
|
||||
{
|
||||
name: "paragraph break",
|
||||
in: "Erste Zeile\n\nZweite Zeile",
|
||||
wantContains: []string{"<p>Erste Zeile</p>", "<p>Zweite Zeile</p>"},
|
||||
},
|
||||
{
|
||||
name: "single newline → br",
|
||||
in: "Zeile A\nZeile B",
|
||||
wantContains: []string{"<p>Zeile A<br>", "Zeile B</p>"},
|
||||
},
|
||||
{
|
||||
name: "script tag escaped",
|
||||
in: "Hallo <script>alert(1)</script>",
|
||||
wantContains: []string{"<script>", "</script>"},
|
||||
wantMissing: []string{"<script>", "alert(1)</script>"},
|
||||
},
|
||||
{
|
||||
name: "link injection attempt — javascript: URL is rejected",
|
||||
in: "[click](javascript:alert(1))",
|
||||
wantMissing: []string{`href="javascript:`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := renderMarkdownSafe(tc.in)
|
||||
for _, want := range tc.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, miss := range tc.wantMissing {
|
||||
if strings.Contains(got, miss) {
|
||||
t.Errorf("unexpected %q in %q", miss, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNameExtraction(t *testing.T) {
|
||||
// senderSignature uses DisplayName directly; firstName extraction is
|
||||
// frontend-side. Smoke-test only that DisplayName placeholder lands.
|
||||
sender := models.User{
|
||||
ID: uuid.New(),
|
||||
Email: "max@hlc.com",
|
||||
DisplayName: "Max Mustermann",
|
||||
}
|
||||
sig := senderSignature("de", sender)
|
||||
if !strings.Contains(sig, "Max Mustermann") {
|
||||
t.Errorf("DisplayName not in signature: %q", sig)
|
||||
}
|
||||
if !strings.Contains(sig, "Gesendet von") {
|
||||
t.Errorf("DE prefix missing: %q", sig)
|
||||
}
|
||||
if !strings.Contains(sig, `mailto:max@hlc.com`) {
|
||||
t.Errorf("mailto link missing: %q", sig)
|
||||
}
|
||||
sigEN := senderSignature("en", sender)
|
||||
if !strings.Contains(sigEN, "Sent by") {
|
||||
t.Errorf("EN prefix missing: %q", sigEN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastValidation exercises the cheap guards that fire before any
|
||||
// SQL or SMTP I/O. Constructed with a nil DB so the tests don't need a
|
||||
// connection string. The Send path bails out at validation before touching
|
||||
// db.ExecContext.
|
||||
func TestBroadcastValidation(t *testing.T) {
|
||||
mailSvc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewMailService: %v", err)
|
||||
}
|
||||
svc := NewBroadcastService(nil, mailSvc, nil, nil, NewEmailTemplateService(nil))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in BroadcastInput
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "empty subject",
|
||||
in: BroadcastInput{Subject: "", Body: "x", Recipients: oneRec()},
|
||||
want: ErrBroadcastEmptySubject,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
in: BroadcastInput{Subject: "Hi", Body: " ", Recipients: oneRec()},
|
||||
want: ErrBroadcastEmptyBody,
|
||||
},
|
||||
{
|
||||
name: "no recipients",
|
||||
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nil},
|
||||
want: ErrBroadcastNoRecipients,
|
||||
},
|
||||
{
|
||||
name: "too many recipients",
|
||||
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nRecipients(BroadcastRecipientCap + 1)},
|
||||
want: ErrBroadcastTooManyRecipients,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
in: BroadcastInput{
|
||||
Subject: "Hi",
|
||||
Body: "x",
|
||||
Recipients: []BroadcastRecipient{{
|
||||
UserID: uuid.New(),
|
||||
Email: "not-an-email",
|
||||
}},
|
||||
},
|
||||
want: ErrBroadcastInvalidEmail,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.Send(t.Context(), uuid.New(), tc.in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// Use errors.Is so wrapped errors still match.
|
||||
if !errorIs(err, tc.want) {
|
||||
t.Errorf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errorIs is a tiny shim so the test file doesn't need to import "errors".
|
||||
// (Imports are kept terse on purpose — see existing test files.)
|
||||
func errorIs(have, want error) bool {
|
||||
if have == want {
|
||||
return true
|
||||
}
|
||||
if have == nil || want == nil {
|
||||
return false
|
||||
}
|
||||
// Fall back to message-level matching for fmt.Errorf %w wraps.
|
||||
return strings.Contains(have.Error(), want.Error())
|
||||
}
|
||||
|
||||
func oneRec() []BroadcastRecipient {
|
||||
return []BroadcastRecipient{{
|
||||
UserID: uuid.New(),
|
||||
Email: "anna@hlc.com",
|
||||
DisplayName: "Anna",
|
||||
FirstName: "Anna",
|
||||
}}
|
||||
}
|
||||
|
||||
func nRecipients(n int) []BroadcastRecipient {
|
||||
out := make([]BroadcastRecipient, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, BroadcastRecipient{
|
||||
UserID: uuid.New(),
|
||||
Email: "user@hlc.com",
|
||||
DisplayName: "User",
|
||||
FirstName: "User",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -421,6 +421,13 @@ func hostnameForHelo() string {
|
||||
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
|
||||
// (umlauts) render correctly in every client.
|
||||
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||||
return buildMIMEWithReplyTo(from, fromName, "", to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// buildMIMEWithReplyTo is buildMIME plus an optional Reply-To header.
|
||||
// Bulk-broadcast email uses this so replies route to the human sender even
|
||||
// though From: stays on the SMTP infrastructure address.
|
||||
func buildMIMEWithReplyTo(from, fromName, replyTo, to, subject, htmlBody, textBody string) []byte {
|
||||
boundary := "paliad-mixed-" + randBoundary()
|
||||
fromHeader := from
|
||||
if fromName != "" {
|
||||
@@ -428,6 +435,9 @@ func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||||
}
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
|
||||
if replyTo != "" {
|
||||
fmt.Fprintf(&b, "Reply-To: %s\r\n", replyTo)
|
||||
}
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||||
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
127
internal/services/markdown.go
Normal file
127
internal/services/markdown.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// markdown.go — minimal Markdown → safe HTML converter for broadcast emails.
|
||||
//
|
||||
// Paliad doesn't pull in a third-party Markdown library — the body subset
|
||||
// senders need is small and predictable, so we render it inline. Inputs are
|
||||
// HTML-escaped first; the renderer then re-introduces a small whitelist of
|
||||
// inline tags (<strong>, <em>, <code>, <a>) and block elements (<p>, <ul>,
|
||||
// <li>, <br>) for the patterns it recognises. Anything we don't recognise
|
||||
// stays escaped, so an attacker who tries to slip a <script> tag through
|
||||
// the compose modal sees a literal "<script>" in the rendered email.
|
||||
//
|
||||
// Supported syntax:
|
||||
// - Paragraphs separated by blank lines.
|
||||
// - Single line break inside a paragraph → <br>.
|
||||
// - **bold** → <strong>bold</strong>
|
||||
// - _italic_ or *italic* → <em>italic</em>
|
||||
// - `inline code` → <code>inline code</code>
|
||||
// - [text](https://link) → <a href="...">text</a>
|
||||
// - Lines starting with "- " or "* " → <ul><li>...</li></ul>
|
||||
//
|
||||
// Out-of-scope (intentional, per t-paliad-147 v1):
|
||||
// - Headings, blockquotes, ordered lists, fenced code blocks, images,
|
||||
// tables. These can be added on demand without changing the contract.
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// renderMarkdownSafe converts Markdown to HTML. Output is safe for direct
|
||||
// embedding in an HTML email body: every byte of input is escaped before
|
||||
// the markdown post-processor runs, and the inline rewriter only re-emits
|
||||
// a small whitelist of tags.
|
||||
func renderMarkdownSafe(src string) string {
|
||||
src = strings.ReplaceAll(src, "\r\n", "\n")
|
||||
src = strings.ReplaceAll(src, "\r", "\n")
|
||||
|
||||
// Split into paragraphs on blank lines.
|
||||
paragraphs := strings.Split(src, "\n\n")
|
||||
var out strings.Builder
|
||||
for _, raw := range paragraphs {
|
||||
p := strings.TrimSpace(raw)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// Bullet lists: every line starts with "- " or "* ".
|
||||
if isBulletList(p) {
|
||||
out.WriteString("<ul>\n")
|
||||
for _, line := range strings.Split(p, "\n") {
|
||||
item := strings.TrimSpace(line)
|
||||
if len(item) >= 2 && (item[:2] == "- " || item[:2] == "* ") {
|
||||
item = strings.TrimSpace(item[2:])
|
||||
}
|
||||
out.WriteString(" <li>")
|
||||
out.WriteString(renderInline(item))
|
||||
out.WriteString("</li>\n")
|
||||
}
|
||||
out.WriteString("</ul>\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// Plain paragraph. Single-newline within → <br>.
|
||||
lines := strings.Split(p, "\n")
|
||||
out.WriteString("<p>")
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
out.WriteString("<br>\n")
|
||||
}
|
||||
out.WriteString(renderInline(strings.TrimSpace(line)))
|
||||
}
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isBulletList(p string) bool {
|
||||
for _, line := range strings.Split(p, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if len(t) < 2 {
|
||||
return false
|
||||
}
|
||||
if t[:2] != "- " && t[:2] != "* " {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var (
|
||||
mdLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^\s)]+)\)`)
|
||||
mdBoldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
mdItalRE1 = regexp.MustCompile(`(^|[^\w])_([^_]+)_($|[^\w])`)
|
||||
mdItalRE2 = regexp.MustCompile(`(^|[^\w*])\*([^*]+)\*($|[^\w*])`)
|
||||
mdCodeRE = regexp.MustCompile("`([^`]+)`")
|
||||
)
|
||||
|
||||
// renderInline applies inline markdown to one line. The input is escaped
|
||||
// first; replacements re-emit whitelisted tags.
|
||||
func renderInline(line string) string {
|
||||
s := html.EscapeString(line)
|
||||
// Order matters: links first (they wrap text+URL), then bold (which is
|
||||
// **…** and would otherwise be split by the italic *…* rule), then
|
||||
// italics, then code.
|
||||
s = mdLinkRE.ReplaceAllStringFunc(s, func(m string) string {
|
||||
matches := mdLinkRE.FindStringSubmatch(m)
|
||||
if len(matches) != 3 {
|
||||
return m
|
||||
}
|
||||
text, url := matches[1], matches[2]
|
||||
// URL is already escaped by html.EscapeString above; href quoting
|
||||
// also needs the &-form so screen readers don't choke.
|
||||
return fmt.Sprintf(`<a href="%s">%s</a>`, url, text)
|
||||
})
|
||||
s = mdBoldRE.ReplaceAllString(s, `<strong>$1</strong>`)
|
||||
s = mdItalRE1.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
||||
s = mdItalRE2.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
||||
s = mdCodeRE.ReplaceAllString(s, `<code>$1</code>`)
|
||||
return s
|
||||
}
|
||||
|
||||
// escapeHTML is a thin alias used by senderSignature so the broadcast file
|
||||
// doesn't need to import html directly.
|
||||
func escapeHTML(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
@@ -152,6 +152,76 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projec
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// MembershipEntry is one row in the team-memberships index.
|
||||
// Powers the /team page project-multi-select filter (t-paliad-147):
|
||||
// the frontend pulls the index once, then filters users locally
|
||||
// by intersecting the UI-selected project_ids against each user's
|
||||
// project_ids list.
|
||||
type MembershipEntry struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProjectIDs []string `json:"project_ids"`
|
||||
// LeadProjectIDs is the subset of project_ids on which this
|
||||
// user has role='lead'. Surfaces the "I am a lead on N projects"
|
||||
// state the broadcast send-button needs.
|
||||
LeadProjectIDs []string `json:"lead_project_ids"`
|
||||
// Role on each project — same indexing as project_ids — so the
|
||||
// frontend can offer a project_teams.role filter.
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
// ListMembershipsIndex returns one row per user × project_team membership
|
||||
// the caller can see. global_admin sees everything; non-admin only sees
|
||||
// memberships on projects whose visibility predicate they pass.
|
||||
//
|
||||
// Membership rows are direct (paliad.project_teams.project_id) only —
|
||||
// inherited memberships are left to the client to compute, since the
|
||||
// project-multi-select filter wants "user is on this exact project"
|
||||
// semantics, not "user inherits from somewhere up the tree".
|
||||
func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UUID) ([]MembershipEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT pt.user_id::text, pt.project_id::text, pt.role
|
||||
FROM paliad.project_teams pt
|
||||
JOIN paliad.projects p ON p.id = pt.project_id
|
||||
WHERE `+visibilityPredicatePositional("p", 1)+`
|
||||
ORDER BY pt.user_id, pt.project_id`,
|
||||
callerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list memberships index: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
byUser := map[uuid.UUID]*MembershipEntry{}
|
||||
for rows.Next() {
|
||||
var userIDStr, projectIDStr, role string
|
||||
if err := rows.Scan(&userIDStr, &projectIDStr, &role); err != nil {
|
||||
return nil, fmt.Errorf("scan membership: %w", err)
|
||||
}
|
||||
uid, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry, ok := byUser[uid]
|
||||
if !ok {
|
||||
entry = &MembershipEntry{UserID: uid}
|
||||
byUser[uid] = entry
|
||||
}
|
||||
entry.ProjectIDs = append(entry.ProjectIDs, projectIDStr)
|
||||
entry.Roles = append(entry.Roles, role)
|
||||
if role == RoleLead {
|
||||
entry.LeadProjectIDs = append(entry.LeadProjectIDs, projectIDStr)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iter memberships: %w", err)
|
||||
}
|
||||
out := make([]MembershipEntry, 0, len(byUser))
|
||||
for _, e := range byUser {
|
||||
out = append(out, *e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func isValidRole(r string) bool {
|
||||
|
||||
Reference in New Issue
Block a user