Files
paliad/internal/db/migrations/057_email_broadcasts.up.sql
m 52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00

92 lines
3.7 KiB
SQL

-- 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());