Compare commits

..

4 Commits

Author SHA1 Message Date
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
m
99f08e3863 Merge: t-paliad-145 design doc only — local chat feature PARKED per m's call (2026-05-07 17:03). Doc preserved in docs/design-local-chat-2026-05-07.md for when it un-parks. 2026-05-07 17:04:10 +02:00
m
dd4f563212 design(t-paliad-145): local chat for teams
Inventor design for in-app messaging surface inside paliad. Three
coordinated sub-designs (surface/visibility, real-time/content/
persistence, integration with existing surfaces) answering all 21
issue-body questions plus 11 inventor follow-ups.

Recommendations:
- Per-project chat + DMs in v1; per-deadline/termin/partner-unit defer
- Per-thread visibility = can_see_project (existing predicate, derivation-aware)
- SSE for real-time (single new long-lived endpoint)
- New: paliad.chat_threads, chat_messages, chat_reads, chat_thread_participants, chat_mentions
- New project_teams.chat_access flag, default OFF for local_counsel/expert
- Approval auto-post into project chat (one auto-event class only)
- Markdown subset, @mentions, entity-refs (#frist-, #projekt-, #termin-, #approval-)
- 5-min author edit window, soft-delete by author or admin
- In-app sidebar badge in v1; PWA push + email digest deferred Phase 2
- Both top-level /chat and per-project Chat tab
- NOT a 5th source in Custom Views (chat is conversation, not events)

Trade-off flagged: adoption risk against existing Slack/WhatsApp.
Recommend m sanity-check with PA colleagues before locking v1 scope.

Migration 057. Awaiting m go/no-go before coder shift.
2026-05-07 16:03:05 +02:00
m
95f6f03cda Merge: t-paliad-144 A2 — Custom Views frontend (Meine Sichten sidebar group + /views list + /views/new editor + /views/{slug}/edit + 3 render shapes list/cards/calendar) 2026-05-07 13:16:48 +02:00
24 changed files with 3559 additions and 2 deletions

View File

@@ -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).

View File

@@ -0,0 +1,947 @@
# Design: Local Chat for Teams (t-paliad-145)
**Status:** READY FOR REVIEW
**Author:** noether (inventor)
**Issue:** [m/paliad#8](https://mgit.msbls.de/m/paliad/issues/8)
**Date:** 2026-05-07
**Branch:** `mai/noether/inventor-local-chat-for`
---
## §0 TL;DR
A new chat surface inside paliad: **per-project threads + 1:1/small-group DMs**, with @mentions and entity references (`#frist-…`, `#projekt-…`, `#approval-…`). Real-time delivery via **SSE** (one new long-lived endpoint). New schema: `paliad.chat_threads` + `paliad.chat_messages` + `paliad.chat_reads` + `paliad.chat_thread_participants` + `paliad.chat_mentions`. Visibility composes the existing `paliad.can_see_project` predicate; write-access adds a `chat_access` flag on `project_teams` (default ON for internal roles, OFF for `local_counsel`/`expert`); `observer` is read-only.
In-app badge in the sidebar (alongside the existing approvals bell). **No PWA push, no email digest, no attachments, no search-across-threads in v1** — all deferred to Phase 2. Markdown subset (bold, italic, code, lists, links, blockquote — no headings, no images). Edit window 5 min by author; soft-delete by author or admin. System auto-post into project chat when an approval is requested on that project (the only auto-event in v1).
Total scope: one migration (`057_chat`), one new service (`ChatService`) + an in-process pubsub (`ChatBus` interface — pg_notify implementation later when paliad multi-replicas), eight HTTP endpoints, one new top-level page `/chat`, one new `/projects/{id}` tab. Estimated ~35004500 LoC for the bundled v1 ship; phasable into 3 PRs (schema + service core, real-time + frontend, mentions + auto-post).
**Trade-off flagged up-front (read §9.1 before approving):** chat-in-paliad collides with HLC's existing internal comms (Slack/Teams/WhatsApp). Compliance is the cited differentiator, but adoption depends on whether team members actually move "Anna, kannst du auf meine Frist 16.05. drauf schauen?" from WhatsApp into paliad. Recommend m sanity-check this with two PA colleagues before locking the v1 scope.
---
## §1 Premises verified live (2026-05-07)
Before designing on top, I verified each load-bearing claim against the running system rather than CLAUDE.md / memory:
| Claim | Source | Verification |
|---|---|---|
| `paliad.notifications` does not exist | issue body Q5/§References | `information_schema.tables WHERE table_schema='paliad'` — confirmed absent. Only `paliad.reminder_log` (email dedup). |
| Service worker is cache-only (no push handler) | brand expectation | `frontend/public/sw.js` — only `install`/`activate`/`fetch` handlers. No `push`, no VAPID keys, no `web-push` Go dep. |
| Supabase realtime is not enabled on this Postgres | infra | `pg_extension WHERE extname='supabase_realtime'` → empty. Adding it is a separate decision (changes paliad's Postgres surface). |
| `paliad.can_see_project()` already extended for derivation | t-139 Phase 2 | Migration 055 added the partner-unit branch; visibility predicate is the canonical entry. |
| `project_teams.role` enum is `{lead, of_counsel, associate, senior_pa, pa, local_counsel, expert, observer}` | t-138 + t-139 | `pg_constraint` on `project_teams_role_check`. Confirms eight values; `observer` is the read-only one. |
| Sidebar has a bell badge `id="sidebar-inbox-badge"` for approvals | t-138 | `frontend/src/components/Sidebar.tsx:118`. Same pattern reused for chat unread badge. |
| BottomNav has exactly 5 slots (Start / Projekte / + / Agenda / Menü) | mobile UX constraint | `frontend/src/components/BottomNav.tsx`. Adding chat to bottom-nav would need swap-out — deferred to per-project tab on mobile. |
| Migration tracker is at version 56 (`056_user_views`) | t-144 A1 | `paliad_schema_migrations` row. Next migration is **057**. |
| `paliad.notes` exists as annotations on deadlines/appointments/project_events | data model v2 | Different concept from chat (annotations, not conversation). Document the distinction so they don't collide. |
| Single web replica today on Dokploy | docker-compose.yml | One `web` service, no horizontal scaling. SSE in-process bus is safe v1; document multi-replica migration path. |
| `feature-roadmap.md` mentions "AI chat" | feature-roadmap.md | This is a different concept (Claude-grounded RAG over paliad content, blocked by no-Anthropic-API decision). Reserve `/chat` for human-to-human; AI chat goes elsewhere if it ever ships (`/ask`, `/assist`, etc.). |
**Doc-vs-live conflicts encountered:** none material. CLAUDE.md and memory are consistent with the live substrate for this task.
---
## §2 What v1 is and what it isn't
### 2.1 In scope (v1)
- **Per-project threads**, one chat per project node. Visible to: same set as `can_see_project()` (direct + ancestor + derived). One thread auto-provisioned on first access.
- **Direct messages (DMs)**: 1:1 + small-group ad-hoc. Recipient picker pulls from any user the caller can already see (i.e. someone who shares a visible project).
- **Plain-text + Markdown subset** (bold, italic, code inline + block, bullet/numbered lists, blockquote, auto-linked URLs). No headings, no images, no inline HTML.
- **@mentions** and **entity references** (`#frist-<short_id>`, `#projekt-<slug>`, `#termin-<short_id>`, `#approval-<short_id>`).
- **Edit** within 5 min, by author. Tombstone-style **delete** by author or admin.
- **Real-time delivery** via SSE.
- **In-app sidebar badge** with unread count.
- **Read marker** per (user, thread).
- **System auto-post**: when an approval request is created on this project's deadlines/appointments, system message in chat ("Anna hat Genehmigung angefordert: …"). One auto-event class only.
- **Chat tab on `/projects/{id}`** (deep-link entry).
- **Top-level `/chat` page** (global view + DM landing).
### 2.2 Out of scope (v1, deferred)
| Feature | Why deferred | Phase |
|---|---|---|
| PWA push notifications | Needs VAPID + push subscription endpoint + SW push handler. Non-trivial; chat MVP works without it. | 2 |
| Email digest of unread chats | Reminder system already saturates email; digest math + SMTP load. | 2 |
| File attachments | `paliad.documents` already exists as the canonical document store; chat reuse is a Phase 2 plumbing exercise. | 2 |
| Cross-thread search | Postgres FTS + visibility join is a separate optimisation. v1 has thread-scoped LIKE search. | 2 |
| Per-deadline / per-termin micro-threads | High-noise risk. Project chat with `#frist-…` references covers most uses. | 3 |
| Partner-unit room ("cross-cutting team room") | Semantically maps to a partner_unit-scoped chat; v2 once project chat usage validates. | 3 |
| Reactions (👍 / 👎) | Issue body lists this as Phase 2. | 2 |
| Threaded sub-replies (Slack-style) | UX complexity + count-math change. Flat threads for v1. | 3 |
| End-to-end encryption | HLC's storage assumptions are server-trusted; defer. | — |
| External-firm chat (opposing counsel etc.) | Compliance + identity boundary. Out of scope, possibly forever. | — |
---
## §3 Sub-design A — Surface set, visibility, permissions
Answers Q1, Q2, Q3, Q19, Q20.
### 3.1 Surface set (Q1)
**Recommendation: project chat + DMs in v1. Defer per-deadline/per-termin/partner-unit/topical.**
| Surface | v1? | Rationale |
|---|---|---|
| Per-project | ✅ | Already-resolved team set, contextual references, replaces "@channel"-style coordination on a project. The high-leverage default. |
| DM (1:1) | ✅ | Replaces "schick mir kurz das Aktenzeichen" WhatsApps. Recipient set = anyone the caller can see. |
| DM (small group, ad-hoc 38) | ✅ | Same plumbing as 1:1 — participants set instead of pair. No project context required. |
| Per-deadline | ❌ Phase 3 | High-noise risk; project chat with `#frist-1234` reference does 95% of the same work. Revisit if usage shows demand. |
| Per-termin | ❌ Phase 3 | Same reasoning as per-deadline. |
| Partner-unit room | ❌ Phase 3 | Maps cleanly onto `partner_units` once we see the surface gain traction. Extra surface for v1 = extra surface to maintain. |
| Cross-cutting topical rooms | ❌ Defer | No clear v1 use case; would need user-driven creation + naming + discovery. Wait for organic demand. |
### 3.2 Visibility model (Q2 — hierarchy)
**Recommendation:**
- **Each project node has its own thread.** A `Client` chat is a separate thread from its child `Litigation`, which is separate from each `Patent` and `Case`. Threads are NOT aggregated up the hierarchy.
- **Read access per thread** = the existing `paliad.can_see_project(project_id)` predicate (which already includes direct + ancestor team, derived partner-unit members, and global_admin). This means a member added at `Client` level sees the Client thread *and* every descendant's thread (because they can already see those projects' deadlines/appointments). Conversely, a member added only at `Case` level sees only the Case thread.
- **Why not aggregate down?** Aggregating ("Client thread = union of all descendant threads") breaks privacy: Case 14-vs-Müller chat content would surface in the Siemens AG Client thread, visible to all Siemens AG team members. Each project-level thread is its own boundary.
- **Why use per-thread visibility = `can_see_project`?** It mirrors every other visibility decision in paliad (deadlines, appointments, events, approvals). One predicate, one mental model. If t-139's derivation rules change, chat tracks for free.
**Practical example:**
```
Client: Siemens AG ← thread S
├─ Litigation: UPC München patent X ← thread L1
│ ├─ Patent: EP1234567 ← thread P1
│ │ └─ Case: 14-vs-Müller ← thread C1
│ └─ Patent: EP7654321 ← thread P2
└─ Litigation: EPO Opposition ← thread L2
```
A member added at `Client` (Siemens AG) sees S, L1, L2, P1, P2, C1. A member added only at `Case 14-vs-Müller` sees only C1. A derived partner-unit member attached at L1 sees L1, P1, P2, C1.
**Anti-feature flagged:** no "broadcast to whole subtree" on send. If a lead wants to message everyone on every Siemens AG thread, they post to the `Client`-level thread; sub-thread members do not get cross-posted. This is intentional — broadcast is a separate UX (Issue #7 bulk team email) and shouldn't be smuggled into chat.
### 3.3 Approval flow integration (Q3 — t-138 cross-cut)
**Recommendation: chat does NOT replace inbox or email for approval. Instead, on approval-request creation, post a system message into the project chat with a deep-link to `/inbox`.**
Rationale:
- Approvals are structured (approve/reject buttons, decision_kind, audit). Chat is unstructured. Conflating them dilutes the structure.
- But chat is where the team's eyeballs live ambiently. Posting "📌 Anna hat Genehmigung angefordert: Frist 16.05. (Replik einreichen). [Zur Genehmigung →]" surfaces the request without forcing anyone to refresh /inbox.
- **De-dup with email + bell:** the system post is informational only. Email reminder + bell badge stay primary signals. If the approver opens the chat first and clicks the deep-link, they reach /inbox; the bell decrements as soon as they act there.
**Mechanism:**
- `ApprovalService.Submit*` calls `ChatService.PostSystemMessage(threadID, kind="approval_requested", refs={approval_id})` inside the same tx as the approval row insert. If chat post fails, log + continue (approval is the load-bearing record; chat is observability).
- One auto-event class only. NOT every deadline-created / appointment-created. Reason: existing Verlauf already captures those; chat would become a duplicate event log.
- System messages render with a distinct chip/style (`.chat-system-message`) — non-author, no edit/delete affordance for users.
**Anti-feature flagged:** approval *decisions* (approve/reject/revoke) do NOT auto-post. Only the *request* posts. This keeps signal density manageable.
### 3.4 Who can read + write (Q19, Q20)
**Recommendation:**
- **Read**: anyone with `can_see_project` access (direct + ancestor + derived + global_admin). Same predicate as deadlines/appointments. No further gating.
- **Write**: same set, minus:
- `observer` — always read-only on chat (mirrors observer's read-only contract on deadlines/appointments).
- `local_counsel` and `expert` — opt-in per project via a new `project_teams.chat_access` boolean. Default `false` for these two roles, `true` for everyone else. Project lead or global_admin can flip the toggle on `/projects/{id}/settings/team`.
**Schema delta (in 057):**
```sql
ALTER TABLE paliad.project_teams
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
UPDATE paliad.project_teams SET chat_access = false
WHERE role IN ('local_counsel', 'expert');
CREATE INDEX project_teams_chat_idx
ON paliad.project_teams (project_id, user_id) WHERE chat_access = true;
```
**Why a boolean instead of a separate `chat_access_role` enum?** External counsel/expert participation in chat is binary in practice ("included or excluded"). Granular ladder isn't needed. If product later wants "external can read but not write", we revisit.
**Why default ON for internal roles?** Path of least surprise: paliad already gives them visibility on all project artifacts; chat read+write is the same trust level.
**Why default OFF for external?** Compliance is the marquee differentiator m cited. External counsel chatting in paliad creates audit/disclosure surface that internal counsel may not anticipate. Default OFF puts the lead in control.
**Derived members (partner-unit derivation, t-139)**: read = visibility (yes). Write = also yes by default (they can already see the project's other artifacts; chat is no more privileged). Derived members do NOT need `chat_access=true` — that flag is on `project_teams` only, which derived members don't appear in. The derivation branch in the read query already covers them; for write, the service-layer check is "caller has any access (direct/ancestor/derived/admin) AND if direct, role != observer AND chat_access != false".
**Service-layer write predicate (Go):**
```go
func (s *ChatService) canPostToProject(ctx context.Context, callerID, projectID uuid.UUID) (bool, error) {
// global_admin shortcut
if isGlobalAdmin, _ := s.users.IsGlobalAdmin(ctx, callerID); isGlobalAdmin {
return true, nil
}
// Direct/ancestor membership with role != observer AND chat_access = true
var directOK bool
err := s.db.QueryRowxContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.role <> 'observer'
AND pt.chat_access = true
AND pt.project_id = ANY(string_to_array((SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
)`, callerID, projectID).Scan(&directOK)
if err != nil { return false, err }
if directOK { return true, nil }
// Derived (partner-unit) membership: observer/external-flag not relevant — derivation has no role
return s.derivation.IsDerivedMember(ctx, callerID, projectID)
}
```
`canRead` is the simpler `can_see_project` mirror — no observer/external gating.
---
## §4 Sub-design B — Real-time, content, persistence
Answers Q4Q15, Q21.
### 4.1 Real-time architecture (Q4)
**Recommendation: Server-Sent Events (SSE).**
| Option | v1 fit | Notes |
|---|---|---|
| (a) Polling | ❌ | Cheap to ship but lossy under tab-sleep, doubles the API load on every active tab. Already a pain point for the bell badge. |
| (b) **SSE** | ✅ | One-way push, native in Go's `net/http` via `http.Flusher`, EventSource auto-reconnects, single endpoint, no per-message connection. Traefik forwards `text/event-stream` with no special config beyond disabling response buffering. |
| (c) WebSockets | Defer | Bidirectional we don't need (post is a regular POST + bus publish). Adds heartbeat/reconnect/sticky-session complexity. Worth it only if v2 surfaces typing-indicators or read-receipts that need bidi. |
**Endpoint shape:**
```
GET /api/chat/stream
Accept: text/event-stream
[Last-Event-ID: <message_id>] ← optional for resume
```
Server emits:
```
event: message
id: <message_id>
data: {"type":"message_created","thread_id":"…","message":{…}}
event: message
id: <message_id>
data: {"type":"message_edited","thread_id":"…","message_id":"…","body":"…","edited_at":"…"}
event: message
id: <message_id>
data: {"type":"message_deleted","thread_id":"…","message_id":"…"}
event: read
data: {"type":"read_advanced","thread_id":"…","user_id":"…","up_to_message_id":"…"}
: ping ← every 25s, keeps Traefik from reaping idle stream
```
Server-side: per-user goroutine subscribed to `ChatBus` (see §4.2). On bus event, filter against the user's visibility (cached at connect; invalidate on team-membership change), encode SSE frame, flush. Connection close = unsubscribe.
**Failure modes + mitigations:**
| Failure | Mitigation |
|---|---|
| Idle proxy reaper (Traefik default ~3min) | Heartbeat comment every 25s. |
| Backpressure if recipient connection is slow | Per-user channel has a small buffer (16). Overflow drops the slow consumer's connection (client EventSource auto-reconnects with `Last-Event-ID`, replay catches up). |
| Multi-replica fanout (future) | Bus interface allows swap to `pgnotify.ChatBus` (LISTEN/NOTIFY on `paliad_chat`) without touching ChatService. |
| HTTP/1.1 6-conn-per-origin browser cap | Document. Single tab = no issue. Multi-tab is a known SSE constraint; users with many tabs will see one tab's stream go silent. Rare in legal-team usage; defer. |
**Disable response compression on this endpoint** in handler (`w.Header().Set("Content-Encoding", "identity")`) to prevent Traefik from buffering.
### 4.2 Message bus (interface)
**`internal/services/chat_bus.go`**:
```go
type ChatEvent struct {
Kind string // message_created | message_edited | message_deleted | read_advanced
ThreadID uuid.UUID
MessageID uuid.UUID // for message_* events
Payload map[string]any
AudienceFn func(uid uuid.UUID) bool // visibility filter — applied per subscriber
}
type ChatBus interface {
Publish(ctx context.Context, ev ChatEvent) error
Subscribe(ctx context.Context, userID uuid.UUID) (<-chan ChatEvent, func())
}
// Default: in-process. Per-user channel registry under sync.Map.
// Future: postgresChatBus uses pg_notify(channel="paliad_chat") for fanout.
```
**Why an interface from day 1?** paliad's deploy is single-replica today (docker-compose, one `web` container on Dokploy). When/if we scale to N, swap in the pg_notify implementation; no callsite changes. Cheap insurance.
### 4.3 Notification path (Q5)
**Recommendation: in-app sidebar badge ONLY in v1. Email digest deferred. PWA push deferred.**
| Channel | v1 | Phase 2 | Phase 3+ |
|---|---|---|---|
| In-app sidebar Chat unread badge | ✅ | | |
| Browser tab title flash on incoming message (foreground tab on chat surface) | ✅ (cheap) | | |
| `Notification` API (foreground, opt-in per browser permission) | ✅ (cheap) | | |
| Email digest of unread-since-last-login | | ✅ | |
| PWA push (background, requires VAPID + SW push handler) | | | ✅ |
| CalDAV alarm | ❌ | ❌ | ❌ Wrong channel — calendar is for time-anchored events. |
**Rationale for deferring PWA push:**
- paliad's `frontend/public/sw.js` is currently a 90-line cache-only worker. Adding push needs:
1. A new `addEventListener('push', …)` and `addEventListener('notificationclick', …)` block.
2. VAPID keypair generation + secure storage (env vars).
3. New table `paliad.push_subscriptions(user_id, endpoint, p256dh, auth, user_agent, created_at)`.
4. Server-side `web-push` Go lib (e.g. `github.com/SherClockHolmes/webpush-go`).
5. New endpoint `POST /api/push/subscribe` + permission-prompt UX.
- Worth ~600800 LoC and a separate review cycle. Don't bundle into chat MVP. Once chat usage is validated, push graduates as a Phase 2 task and serves chat + approvals + reminders together (one push pipeline, multiple producers).
**Rationale for deferring email digest:**
- Mail volume is already a friction point — t-paliad-064 just collapsed reminders into bundled digests. Layering an unread-chat email on top would re-saturate.
- Once usage shows it's needed, the digest can compose with the existing morning/evening slot reminder pipeline.
### 4.4 Read / unread + delivery state (Q6)
**Recommendation: per-(user, thread) last-read marker. No per-message read receipts.**
```sql
CREATE TABLE paliad.chat_reads (
user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
thread_id uuid REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, thread_id)
);
```
**Unread count for sidebar badge:**
```sql
SELECT COUNT(*)
FROM paliad.chat_messages m
WHERE m.thread_id IN (<visible thread ids for caller>)
AND m.deleted_at IS NULL
AND m.author_id <> $caller
AND (
NOT EXISTS (SELECT 1 FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
OR m.created_at > (SELECT cr.last_read_at FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
);
```
Optionally cap at 99+ in UI.
**Why no per-message read receipts?** Privacy concern (legal team won't want "Anna saw your message 14 min ago, didn't reply"). UX clutter. Slack made the same call (workspace-default).
**`last_read_message_id` on `chat_reads`** is for "scroll to the boundary" UX — when you open a thread, the client scrolls to the message immediately above the marker and inserts a "neue Nachrichten" divider. The boundary is sticky until you mark-read again.
### 4.5 Message body (Q7)
**Recommendation: stored as Markdown source, rendered with a small whitelisted renderer.**
| Render | v1 | Notes |
|---|---|---|
| Bold (`**`/`__`) | ✅ | |
| Italic (`*`/`_`) | ✅ | |
| Inline code (` `` `) | ✅ | |
| Code block (```` ``` ````) | ✅ | Three-backtick fenced; preserves whitespace. |
| Bullet list | ✅ | |
| Numbered list | ✅ | |
| Blockquote (`>`) | ✅ | |
| Auto-link URLs | ✅ | `https?://` patterns auto-wrap as anchor with `target=_blank rel=noopener`. |
| Headings (`#`) | ❌ | Chat ≠ doc. Strip to plain text on render. |
| Images / embeds | ❌ | Use attachments (Phase 2). |
| Inline HTML | ❌ | Always sanitised out. |
| Raw URLs | ✅ | Auto-link them. |
**Library choice:** Server-side, use a small custom renderer or `github.com/yuin/goldmark` with a whitelist extension. Either works; I lean toward a tiny custom one (~150 LoC) because the subset is small and goldmark imports a lot. Frontend is render-only — server delivers HTML-rendered + raw source; client picks based on edit/view state.
**Storage:** raw Markdown source in `paliad.chat_messages.body`. Rendered HTML is computed on read (cheaply; cache in a separate column if benchmarks ever justify). Rendering on read keeps mention/entity-ref resolution dynamic (a deleted deadline's `#frist-…` chip degrades to a dimmed pill instead of a stale link).
### 4.6 Mentions + entity references (Q8)
**Recommendation: yes for v1 — `@user` + `#frist-…` + `#projekt-…` + `#termin-…` + `#approval-…`.**
**Resolution:**
- **Compose-side**: client-side autocomplete on `@` and `#`. Hits `/api/chat/autocomplete?q=…&context=<thread_id>` — server returns a small list scoped to the thread's visibility (mentions: thread members; entities: project's items + globally-visible items the caller can see).
- **Persist-side**: on POST, server parses tokens `@<slug>` / `#<entity>-<short_id>`, resolves to UUIDs, stores in `paliad.chat_mentions` (for users) and in `metadata.entity_refs` JSON (for entities). Original Markdown source preserves the `@anna` / `#frist-1234` syntax.
- **Render-side**: on read, server renders tokens as HTML with deep-links: `<a class="chat-mention" href="/team#user-<uuid>">@anna</a>`, `<a class="chat-entity-ref entity-frist" href="/deadlines/<id>">Frist 1234</a>`. Rendering re-checks visibility per recipient — invisible references render as dimmed `<span class="chat-entity-ref dimmed">[#frist-…]</span>`.
**Notification effect**: a mention drives a unread-count bump. A future Phase 2 enhancement: a separate "Erwähnungen" tab on `/chat` that filters to messages mentioning the caller, with a separate badge. v1 just lifts the unread-count visibility (mention or no mention, the badge ticks).
### 4.7 Attachments (Q9)
**Recommendation: out of scope for v1. Reference existing `paliad.documents` via `#dok-<id>` is a v2 pattern.**
Rationale:
- `paliad.documents` is the canonical document store with metadata (folder, tags, ACL planned). Adding a parallel attachment surface from chat would create two upload pipelines.
- v1 chat references existing documents via entity-ref `#dok-<id>` (deferred until v2 for the implementation; the syntax is reserved now).
- v2 attachment flow: drag-and-drop into chat → uploads into `paliad.documents` → message body gains a `#dok-<id>` reference. Single document, two surfaces.
### 4.8 Edits / deletions (Q10)
**Recommendation:**
- **Edit** allowed within 5 min of post. After 5 min, edit affordance is hidden — correct via reply.
- **Delete** allowed at any time by author. Soft-delete: `deleted_at` set, body replaced server-side with the rendered tombstone "Diese Nachricht wurde gelöscht." in the API payload (DE/EN per `users.lang`). Client renders the tombstone in muted styling. Mentions/entity-refs in deleted messages are preserved server-side (audit) but suppressed in render.
- **Admin override**: global_admin can delete any message at any time. Audit-marked: `metadata.deleted_by_admin = <admin_id>` and a system-message in the same thread "Admin hat eine Nachricht entfernt." (no body content disclosed).
**Why 5 min?**
- Long enough for typo undo, short enough to keep audit trust ("the message you just read isn't the one stored an hour later").
- Mirrors many legal-team chat tools (Slack's default-edit-window can be configured; Teams has 0 by default but admin can extend).
- Edit shows `(bearbeitet)` chip with tooltip showing `edited_at`.
**Why soft-delete only?** Compliance: paliad may need to demonstrate message provenance even after deletion. Soft-delete keeps the row + author + created_at; only `body` is hidden. Hard-delete is escalation-only (manual SQL by global_admin if legal forces).
### 4.9 Replies / threading (Q11)
**Recommendation: flat threads in v1. Slack-style sub-threads deferred.**
A flat thread means every message in the project chat lands at the bottom, ordered chronologically. To reply to a specific message, quote it (`>` Markdown blockquote) or @mention the author — same pattern Twitter/Mastodon use successfully without sub-threads.
**Why flat?**
- Sub-threads add: a `parent_message_id` column, a parent-thread-summary fold-out UI, "thread of threads" navigation, separate unread counts for thread vs sub-thread.
- For project-team chat (515 active members per project), flat is cleaner. Sub-threads pay off in larger channels (50+ members, parallel conversations).
- Re-introduce in v2 if usage shows specific demand for parallel parlay.
### 4.10 Search (Q12)
**Recommendation: thread-scoped search in v1. Cross-thread search deferred.**
- Thread-scoped: `WHERE thread_id = $1 AND body ILIKE '%' || $2 || '%' AND deleted_at IS NULL` — sub-second on threads up to ~10k messages. Above that, add a Postgres FTS index in v2.
- Cross-thread: would need `paliad.chat_messages` FTS + visibility join — workable but separable. Defer to Phase 2 once we know the cross-thread use case.
### 4.11 Storage schema (Q13)
**Migration 057:**
```sql
-- paliad.chat_threads ----------------------------------------------------------
CREATE TYPE paliad.chat_thread_kind AS ENUM ('project', 'dm');
CREATE TABLE paliad.chat_threads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind paliad.chat_thread_kind NOT NULL,
project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
-- DM 1:1 / small group: participants in chat_thread_participants;
-- project: visibility predicate (no rows in chat_thread_participants).
title text, -- DM small-group: optional user-supplied; project: NULL (use project name)
created_by uuid REFERENCES paliad.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
last_activity timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chat_thread_kind_consistency CHECK (
(kind = 'project' AND project_id IS NOT NULL) OR
(kind = 'dm' AND project_id IS NULL)
)
);
-- One project = one thread (idempotent provisioning).
CREATE UNIQUE INDEX chat_threads_project_idx ON paliad.chat_threads (project_id) WHERE kind = 'project';
CREATE INDEX chat_threads_activity_idx ON paliad.chat_threads (last_activity DESC);
-- paliad.chat_thread_participants ---------------------------------------------
CREATE TABLE paliad.chat_thread_participants (
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
joined_at timestamptz NOT NULL DEFAULT now(),
role text NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')),
-- 'admin' on a DM = the creator who can add/remove participants. Project chats have no rows here.
PRIMARY KEY (thread_id, user_id)
);
CREATE INDEX chat_thread_participants_user_idx ON paliad.chat_thread_participants (user_id);
-- paliad.chat_messages --------------------------------------------------------
CREATE TABLE paliad.chat_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- author_id NULL = system message (auto-post)
body text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
-- metadata: { system: true, system_kind: "approval_requested", entity_refs: [...], deleted_by_admin: <uuid>, … }
edited_at timestamptz,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX chat_messages_thread_idx ON paliad.chat_messages (thread_id, created_at DESC);
CREATE INDEX chat_messages_author_idx ON paliad.chat_messages (author_id, created_at DESC);
-- paliad.chat_reads -----------------------------------------------------------
CREATE TABLE paliad.chat_reads (
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, thread_id)
);
-- paliad.chat_mentions --------------------------------------------------------
CREATE TABLE paliad.chat_mentions (
message_id uuid NOT NULL REFERENCES paliad.chat_messages(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
PRIMARY KEY (message_id, user_id)
);
CREATE INDEX chat_mentions_user_idx ON paliad.chat_mentions (user_id);
-- paliad.project_teams.chat_access -------------------------------------------
ALTER TABLE paliad.project_teams
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
UPDATE paliad.project_teams SET chat_access = false
WHERE role IN ('local_counsel', 'expert');
-- RLS ------------------------------------------------------------------------
ALTER TABLE paliad.chat_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_thread_participants ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_reads ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_mentions ENABLE ROW LEVEL SECURITY;
-- Service-role bypasses RLS (paliad's pgx pool runs as service-role per t-paliad-088 lesson #2).
-- Policies exist for any future direct-DB query path; service-layer is the load-bearing gate.
-- (RLS predicates omitted from this design doc; sketch in §13 of impl plan.)
```
**`chat_threads.kind = 'dm'` uniqueness for 1:1**: enforce via service layer (sort participant UUIDs, check existing thread with exact participant set). Not in the schema CHECK because participants live in another table.
### 4.12 Retention (Q14)
**Recommendation: forever in v1. Soft-delete only. Phase 2 = export/archive flow.**
Compliance: HLC may need permanent archival of project-related conversations. Forever-storage is the safest default; cheaper than implementing rolling-window deletion + getting it wrong.
`deleted_at` is soft-delete by user/admin action; no time-based purge in v1.
**Phase 2** features for retention/compliance:
- Export thread to PDF/JSON (audit trail).
- Per-project retention override (e.g. Case closed → archive after 6 months).
- Search across archived (read-only) threads.
### 4.13 Audit / Verlauf integration (Q15)
**Recommendation: chat does NOT appear in Verlauf by default. Optional "Pin to Verlauf" affordance on individual messages → creates a `note` (Phase 2).**
Rationale:
- Verlauf already has 18 distinct `event_type` values (`deadline_*`, `appointment_*`, `checklist_*`, `note_*`, `project_type_changed`). Adding `chat_message_*` events for every chat post would dilute signal — Verlauf should answer "what changed on this matter" not "what was said".
- The "Pin to Verlauf" affordance lets users explicitly promote a chat message to a `paliad.note` (and emit a `note_created` event_type — the existing pattern). Phase 2; reserve the UX hook now.
### 4.14 PWA push (Q21)
**Recommendation: defer to Phase 2.** See §4.3 above for the cost/value reasoning. v1 ships without push; users get unread badge + tab-flash + foreground `Notification` API.
---
## §5 Sub-design C — Integration with existing surfaces
Answers Q16Q18.
### 5.1 Sidebar entry (Q16)
**Recommendation: BOTH — a top-level `Chat` sidebar entry with global unread badge AND a per-project `Chat` tab on `/projects/{id}` deep-linking the same thread.**
**Sidebar:**
```
Übersicht
├─ Home
├─ Dashboard
├─ Agenda
├─ Inbox (bell badge — approvals)
├─ Chat (new — chat badge — unread messages) ← new
└─ Team
Arbeit
└─ …
Meine Sichten
└─ …
```
Position: directly under `Inbox`. Same group ("Übersicht"). Same badge pattern (`id="sidebar-chat-badge"`).
**`/chat` page** (new top-level):
- Two-pane layout: thread list left (recently-active first), active thread right (messages + composer).
- Thread list shows: project chats the user has access to (sorted by `last_activity DESC`), DMs (sorted by `last_activity DESC`).
- Tabs: `Alle` (default), `Projekte`, `DMs`, `Erwähnungen` (Phase 2). Visual style mirrors `/inbox` tab chips.
**Per-project Chat tab on `/projects/{id}`:**
- Adds a new "Chat" tab next to existing tabs (Übersicht / Fristen / Termine / Verlauf / Team / …). Tab opens the same project thread, full-width-in-tab.
- Deep-links: `/projects/<id>?tab=chat` and `/projects/<id>/chat` (server resolves both).
**Mobile (BottomNav)**:
- BottomNav slots are full at 5 (Start / Projekte / + / Agenda / Menü). Don't swap a slot — chat surfaces from `Menü` and from per-project `Chat` tab. Defer dedicated mobile slot to Phase 2 once usage justifies.
### 5.2 Custom Views (#5) integration (Q17)
**Recommendation: chat messages are NOT a 5th source in the ViewService union.**
Rationale:
- ViewService unions four kinds: deadline, appointment, project_event, approval_request. Each is a *time-anchored event* with a structured semantics ("Frist X due on Y"). Chat messages are conversation, not events.
- Adding a `chat_message` source would dilute the substrate's purpose and produce noisy Custom Views ("show me everything in the next 30 days" → 80% chat noise).
- **Mentions and entity-refs do NOT cross over either.** A `#frist-1234` reference inside chat doesn't promote the chat message into the deadline's audit; the reference is a navigation aid, not an audit fact.
**Phase 2 escape hatch**: if demand emerges for "show me activity (chat + events) on this project", introduce a new `chat_activity` synthesised source that emits one row per *day-bucket-with-N-messages*, not per-message. That keeps Custom Views unflooded while exposing the "this project has been busy" signal. Reserve the source name; don't implement v1.
### 5.3 Bulk team email (#7) overlap (Q18)
**Recommendation: distinct surfaces, deliberate split.**
| Use case | Bulk email (#7) | Chat |
|---|---|---|
| Team-wide announcement, no reply expected ("Server-Wartung am Montag 18 Uhr") | ✅ | — |
| Coordination on a specific matter ("Anna, kannst du auf meine Frist 16.05. drauf schauen?") | — | ✅ |
| Process reminders / quarterly newsletters | ✅ | — |
| "Wer sieht heute den 14:00 hearing-call?" | — | ✅ |
| External-counsel briefing | ✅ (mail) | — (chat is internal-only by default) |
| Hot-fix coordination during litigation prep | — | ✅ |
| Birthday / kudos (if product wants it) | — | ✅ |
**Pattern:**
- **Email** is broadcast, archive-friendly, no expectation of synchronous reply, lives in user's regular inbox alongside client mail.
- **Chat** is back-and-forth, ambient, threaded, scoped to a project's team or a small DM group.
The two coexist; users self-select. No automatic cross-posting. If a user writes a chat post that is "really" a broadcast announcement, that's a soft heuristic the product can teach later (Phase 3 nudge: "Looks like a broadcast — send as email instead?").
---
## §6 Inventor follow-up questions for m
Beyond the 21 questions in the issue body, my design surfaced a few I cannot lock without a call:
| # | Question | Recommendation |
|---|---|---|
| Q22 | **DM creation policy**: anyone-to-anyone, or scoped to "people I share a project with"? | Recommend: scoped to "people I share at least one visible project with" — keeps DMs inside the matter graph, prevents random cross-firm pings. global_admin always reachable. |
| Q23 | **DM small-group cap**: hard limit on DM participants? | Recommend cap at **8** (informal coordination tier; above that, a project chat or partner-unit room is right). Not a hard schema cap; service-layer + UI cap. Lift later if we see demand. |
| Q24 | **Project chat auto-provision timing**: lazily on first read, or eagerly on project create? | Recommend lazy. Most projects never get chatter; lazy provisioning saves rows + noise on `/projects` list. Once demand is shown, switch to eager (one-line change). |
| Q25 | **System auto-post audience**: project chat post is visible to ALL thread members, including external counsel + observer. Is approval-request system-post leaking signal that external counsel shouldn't see? | Recommend: respect existing `chat_access` flag — observer reads, external counsel reads only if their `chat_access=true`. The same predicate as any chat post; the system just authors instead of a user. |
| Q26 | **Edit window length**: 5 min as recommended, or shorter (1 min) / longer (15 min) / no edit at all? | Recommend 5 min. 1 min is too short for "wait did I tag the right person", 15 min is long enough for someone else to have read+replied based on the original. |
| Q27 | **Markdown subset**: include or exclude blockquote? Tables? Strikethrough? | Recommend: blockquote ✅ (quoting prior message is the flat-thread alternative to sub-threading). Tables ❌ (unusual in chat, complicates renderer). Strikethrough ❌ (chat ≠ doc; rare and ambiguous). |
| Q28 | **`@everyone` / `@team`**: support a "ping the whole project team" mention? | Recommend: NO in v1. Spam risk. Lead can post a normal message; team members on the thread already see it. Phase 2: optional `@team` for project leads only, with confirmation prompt. |
| Q29 | **Chat unread badge: count messages or count threads?** | Recommend: count *messages* with caps at 99+. Threads-with-unread is easier on the eye but obscures volume; messages match user mental model of "you have N things to read". |
| Q30 | **Sound on incoming message** (foreground tab)? | Recommend: NO by default. Opt-in setting (`users.chat_sound_enabled`) deferred to Phase 2. Lawyers in court rooms with their phone open is a real failure mode. |
| Q31 | **Default landing on `/chat`**: most-recently-used thread, "Alle" thread list, or empty placeholder? | Recommend: most-recently-used thread (mirrors `/views` MRU pattern from t-144). First-time users land on an empty-state "Wähle einen Thread links". |
| Q32 | **Chat post triggers `last_activity` bump** on associated project (drives sidebar sort, dashboard "recent activity") — yes/no? | Recommend: yes for chat threads themselves (sort thread list). NO for `paliad.projects.last_modified` (chat shouldn't ride sibling sort signals — that's reserved for case-substantive changes). |
m's go on these locks the design. If any answer flips, I rev the doc before handing to the implementer.
---
## §7 Trade-offs and risks
### 7.1 Adoption risk (the elephant in the room)
**The biggest risk is not technical — it's whether teams actually use this.** HLC colleagues already have:
- WhatsApp + Telegram for fast informal coordination.
- Microsoft Teams / Outlook chat for firm-internal IM (assumption — verify).
- Email for formal asynchronous comms.
paliad chat would need to attract `"Anna, kannst du auf meine Frist 16.05. drauf schauen?"` away from those tools. The differentiator m cited in the issue is compliance + context-rich (auto-resolve `#frist-1234`, team set is pre-derived). That's plausible — but the cost of building it is real, and if PA colleagues stick to WhatsApp, paliad chat becomes a half-empty room that signals "this product doesn't know its users".
**Recommendation before implementation:** m asks two PA colleagues from different offices ("would you actually use this if it existed?", "what would make you switch from WhatsApp?"). Either keeps you honest or surfaces feature gaps the design doesn't cover.
If adoption looks weak, alternative scopes worth considering:
- **A: ship project chat only (no DM).** Project context is the real differentiator; DM is what WhatsApp does well. Less surface, less work, less risk of half-empty.
- **B: ship `@mention + reply` as a comment thread on each deadline/termin first** — closer to the Verlauf pattern, lower lift, and validates the idea before the full chat surface.
### 7.2 Single-replica SSE constraint
Today's docker-compose is one `web` container. SSE works fine. If we ever scale (multi-Dokploy-replica, blue-green deploy with overlap), in-process bus drops cross-replica messages.
**Mitigation:** abstract `ChatBus` interface from day 1. Future `pgnotify.ChatBus` implementation is ~80 LoC and a one-line wiring change. Document this in `internal/services/chat_bus.go`.
### 7.3 Observer-write semantics
`observer` role is read-only for chat per recommendation. There's a UX edge case: an observer who *thinks* they're a regular member (because everyone else is chatting) and gets a write-disabled composer. Mitigate with a clear empty composer hint: "Du bist Beobachter:in für dieses Projekt — Lesezugriff nur." Same pattern as observer's read-only Frist edit.
### 7.4 External counsel default OFF chat
Defaulting `chat_access=false` for `local_counsel`/`expert` is the right compliance default but creates onboarding friction: the first time external counsel is added to a project, the lead has to explicitly toggle them in. **Mitigate** with a one-time hint in the team-add modal: "Externe Anwält:in/Sachverständige:r — Chat ist standardmäßig deaktiviert. Aktivieren?".
### 7.5 Markdown sanitisation correctness
Hand-rolling a small Markdown subset risks XSS through subtle edge cases (`[click](javascript:…)`, malformed image URI, etc.).
**Mitigate:**
- Escape all rendered text first, then apply whitelisted Markdown tokens.
- For URLs: validate `https?://` prefix with stdlib `url.Parse`; reject everything else.
- Add a render test suite with known-bad payloads (data URIs, javascript: URIs, broken closures).
- If we end up importing goldmark anyway, lean on its strict mode + a custom rendering walker.
### 7.6 Chat as a Verlauf-substitute
Risk that users start treating chat as the audit log ("I told Anna in chat to extend that deadline"). Verlauf is the audit; chat is conversation. Mitigate by:
- The Phase 2 "Pin to Verlauf" affordance promotes specific chat messages to notes.
- UX copy on the chat composer: "Notizen am Vorgang? → Verlauf." (small hint, not a wall).
### 7.7 Mobile keyboard + composer + bottom-nav
Mobile keyboards on iOS Safari overlap fixed bottom-nav elements. The composer needs to play nicely with that — anchor at viewport-bottom but adjust on focus. Standard pattern (the existing checklist comment composer probably has the same issue solved). Worth a quick check in implementation, not a design blocker.
### 7.8 chat_messages explosion
Multiplied across all paliad projects, chat could grow to millions of rows over years. Indexes on `(thread_id, created_at DESC)` keep reads fast. PG handles 10M+ rows with ease at this index shape. Storage cost is negligible. Document the size projection in the impl plan but don't pre-optimize.
---
## §8 Phasing
**Phase 1 (chat MVP — bundled v1, single PR):** ~35004500 LoC
1. Migration 057 (chat schema + `project_teams.chat_access`).
2. `ChatService` + `ChatBus` interface + in-process implementation.
3. HTTP endpoints (8 in §10).
4. SSE stream endpoint with heartbeat + Last-Event-ID resume.
5. `frontend/src/chat.tsx` + client `client/chat.ts` + Markdown renderer.
6. `frontend/src/components/Sidebar.tsx` updated with Chat entry + badge.
7. Per-project Chat tab on `/projects/{id}`.
8. Approval auto-post wiring in `ApprovalService.Submit*`.
9. ~80 i18n keys DE+EN.
10. CSS for chat shell + bubbles + mention chips + composer.
**Phase 2** (~2000 LoC each, separate PRs as demand justifies):
- Email digest of unread chats (composes with reminder pipeline).
- PWA push notifications (VAPID + SW push handler + subscription endpoint).
- File attachments (chat → `paliad.documents`).
- Cross-thread search (FTS index + global search).
- "Pin to Verlauf" affordance.
**Phase 3+** (defer until Phase 1+2 usage validates):
- Per-deadline / per-termin micro-threads.
- Partner-unit rooms.
- Reactions, sub-threads, `@team`, sound/Notification config UI.
- Topical/cross-cutting rooms.
**Optional Phase 1 split (if implementer prefers):**
- 1A — Schema + `ChatService` + REST endpoints + project chat shell. No DMs, no SSE (polling stub for unread badge).
- 1B — DMs + SSE + mentions + entity-refs + approval auto-post.
If the implementer splits, they own the call. Both 1A+1B in a single PR is ~4500 LoC; each in its own PR is ~2000-2500. m can decide on the split when locking the design.
---
## §9 Implementer recommendation
**Recommended worker: noether (this worktree)** or a fresh coder.
Pattern-fluent Sonnet work; nothing here requires Opus-level architectural reasoning past this design. The substrate (visibility predicate, project_teams shape, SSE handling, sidebar/badge pattern, ViewService precedent) is well-trodden — implementation is mostly composition.
NOT cronus per memory directive (cronus retired from paliad).
Expected files (Phase 1):
- `internal/db/migrations/057_chat.{up,down}.sql`
- `internal/services/chat_service.go`
- `internal/services/chat_bus.go`
- `internal/services/markdown.go` (small renderer)
- `internal/handlers/chat.go`
- `internal/handlers/chat_stream.go` (SSE)
- `internal/handlers/handlers.go` (route wiring under `if svc.Chat != nil`)
- `internal/services/approval_service.go` (auto-post hook on Submit*)
- `cmd/server/main.go` (`chatBus := services.NewInProcessChatBus(); chatSvc := services.NewChatService(pool, …, chatBus)`)
- `frontend/src/chat.tsx` (page shell)
- `frontend/src/projects-detail.tsx` (Chat tab integration)
- `frontend/src/client/chat.ts` (orchestration, EventSource, autocomplete, edit/delete, read marker)
- `frontend/src/client/markdown.ts` (render-side companion if any)
- `frontend/src/client/sidebar.ts` (badge + unread-count fetch)
- `frontend/src/components/Sidebar.tsx` (new Chat entry)
- `frontend/src/styles/global.css` (chat-shell + chat-bubble + chat-mention + chat-composer styles)
- `frontend/src/i18n.ts` (~80 keys DE+EN)
- `frontend/src/build.ts` (chat.html bundle)
---
## §10 HTTP endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/chat` | Chat shell page |
| GET | `/chat/dm/<thread_id>` | Deep-link to specific DM thread (server resolves visibility, redirects to /chat with state) |
| GET | `/api/chat/threads` | List threads visible to caller (project + DM), sorted by last_activity. Includes per-thread unread count. |
| POST | `/api/chat/dm` | Body `{ "participant_ids": [...], "title": "..." }`. Returns thread (idempotent for 1:1 by sorted participant set). |
| GET | `/api/chat/threads/<id>/messages?before=<msg_id>&limit=50` | List messages with cursor pagination. Returns rendered HTML + raw source per message. |
| POST | `/api/chat/threads/<id>/messages` | Post message. Body `{ "body": "..." }`. Server parses mentions/refs, inserts, publishes bus event. |
| PATCH | `/api/chat/messages/<id>` | Edit (5min author window). Body `{ "body": "..." }`. |
| DELETE | `/api/chat/messages/<id>` | Soft-delete (author or admin). |
| POST | `/api/chat/threads/<id>/read` | Body `{ "up_to_message_id": "..." }`. Updates `chat_reads`. |
| GET | `/api/chat/unread-count` | Sidebar badge. Returns `{ "total": N, "by_thread": {...} }`. |
| GET | `/api/chat/autocomplete?q=&context=<thread_id>` | Server-resolved mention/entity-ref autocomplete. |
| GET | `/api/chat/stream` | SSE long-lived; returns events filtered to caller's visibility. |
---
## §11 Frontend shape (Phase 1)
```
/chat [/chat]
┌───────────────────┬──────────────────────────────────────┐
│ THREADS │ Siemens AG · Litigation UPC München │
├───────────────────┼──────────────────────────────────────┤
│ Alle | Proj | DM │ ┌──────────────────────────────────┐ │
│ │ │ Anna · 14:23 │ │
│ ▶ Siemens AG · L. │ │ Hat jemand auf Frist #frist-1234 │ │
│ 3 ungelesen │ │ drauf geschaut? Replik bis Mo. │ │
│ ▷ EP1234567 │ └──────────────────────────────────┘ │
│ ▷ DM mit Anna │ ┌──────────────────────────────────┐ │
│ ▷ DM (3) UPC-Team │ │ ── neue Nachrichten ────────────│ │
│ │ ├──────────────────────────────────┤ │
│ │ │ Lukas · 14:30 │ │
│ │ │ Schau gleich rein, ist das EAU? │ │
│ │ └──────────────────────────────────┘ │
│ │ │
│ ├──────────────────────────────────────┤
│ │ ┌──────────────────────────────────┐ │
│ │ │ @anna danke! … ▶│ │
│ │ └──────────────────────────────────┘ │
└───────────────────┴──────────────────────────────────────┘
```
On mobile: thread list is a full page, tap → message page.
On `/projects/{id}?tab=chat`: messages pane only (thread list hidden), with project header above.
System-post visual:
```
┌───────────────────────────────────────────────┐
│ 🔔 Anna hat Genehmigung angefordert: │
│ Frist 16.05. (Replik einreichen) │
│ [ Zur Genehmigung → ] │
└───────────────────────────────────────────────┘
```
(Distinct background, no edit/delete affordance, deep-link button.)
---
## §12 Concrete recommendation summary
| # | Question | Recommendation |
|---|---|---|
| Q1 | Surface set v1 | Per-project + DMs |
| Q2 | Hierarchy visibility | Per-thread, predicate = `can_see_project` |
| Q3 | Approval cross-cut | System auto-post, no replacement |
| Q4 | Real-time arch | SSE |
| Q5 | Notification path | In-app badge + tab-flash + Notification API; defer push + email digest |
| Q6 | Read/unread | Per-(user,thread) last-read marker; no per-message receipts |
| Q7 | Body format | Markdown subset (no headings, no images) |
| Q8 | Mentions + refs | `@user`, `#frist-…`, `#projekt-…`, `#termin-…`, `#approval-…` |
| Q9 | Attachments | Defer Phase 2 |
| Q10 | Edits / deletes | Edit ≤5 min author; soft-delete author or admin |
| Q11 | Threading | Flat |
| Q12 | Search | Thread-scoped LIKE; defer cross-thread |
| Q13 | Schema | Migration 057: 5 new tables + `project_teams.chat_access` |
| Q14 | Retention | Forever, soft-delete only |
| Q15 | Verlauf | No; "Pin to Verlauf" Phase 2 |
| Q16 | Sidebar entry | Both — top-level Chat + per-project tab |
| Q17 | Custom Views | NOT a 5th source |
| Q18 | Bulk email overlap | Distinct surfaces |
| Q19 | Who can chat | Read = visibility; observer read-only; external opt-in via `chat_access` |
| Q20 | External counsel/expert | Default `chat_access=false`; lead toggles per project |
| Q21 | PWA push | Defer Phase 2 |
| Q22 | DM reachability | Scoped to "shares ≥1 visible project" |
| Q23 | DM small-group cap | 8 |
| Q24 | Auto-provision | Lazy on first read |
| Q25 | System-post audience | Same as any chat post (respects `chat_access`) |
| Q26 | Edit window | 5 min |
| Q27 | Markdown subset | Blockquote yes; tables no; strikethrough no |
| Q28 | `@everyone` / `@team` | No in v1 |
| Q29 | Badge count | Messages, capped at 99+ |
| Q30 | Sound | No (opt-in deferred) |
| Q31 | Default landing | Most-recently-used thread |
| Q32 | last_activity bump | Yes on chat thread; no on project record |
---
## §13 Open follow-ups (not for v1)
- **Bot integrations** (e.g. /Frist-Bot for natural-language deadline lookup). Out of scope; if AI chat (`feature-roadmap.md`) ever ships, it lives at `/ask` not `/chat`. Reserve mental separation.
- **External-firm participants** (opposing counsel, expert witnesses outside HLC). Big compliance question; not v1 / not v2 / not yet.
- **Slack / Teams bridging**. Very tempting and very complex (auth, identity mapping, message format translation). Defer until paliad chat usage justifies.
- **Voice messages** (German lawyers love voice notes). Out of scope.
---
## §14 What I need from m to lock
1. **§7.1 adoption sanity-check**: are PAs likely to use this, or is it a half-empty surface?
2. **Q1 — surface set**: confirm per-project + DMs, defer per-deadline / per-termin / partner-unit / topical.
3. **Q4 — SSE**: confirm SSE direction.
4. **Q5 — notification path**: confirm in-app-only v1 (push + email digest deferred).
5. **Q19/Q20 — chat_access flag** on `project_teams`, defaulting OFF for `local_counsel`/`expert`.
6. **Q22Q32 — the 11 follow-up questions** in §6.
7. **§8 phasing** — single PR or 1A+1B split.
If m greenlights with "I agree with all your recommendations - go." (the Q4 of t-139 pattern), I lock the design and the head routes the coder shift.
If m flips any answer, I rev the doc before handover.
**Inventor parks here.** No coder self-load.
---
## §15 Appendix — file/index inventory
For the implementer's reference; verified live 2026-05-07.
**Existing tables touched:**
- `paliad.project_teams` — new column `chat_access`, backfill external roles.
- `paliad.projects` — read-only, source for `path` traversal.
- `paliad.users` — read-only, FK target.
- `paliad.partner_unit_members`, `paliad.project_partner_units` — read-only, derivation predicate.
**New tables:**
- `paliad.chat_threads`
- `paliad.chat_thread_participants`
- `paliad.chat_messages`
- `paliad.chat_reads`
- `paliad.chat_mentions`
**Existing Go services touched:**
- `internal/services/visibility.go` — read-only reuse.
- `internal/services/derivation_service.go` — read-only reuse for partner-unit derivation check.
- `internal/services/approval_service.go` — auto-post hook on `Submit*`.
**New Go services:**
- `internal/services/chat_service.go`
- `internal/services/chat_bus.go` (interface + in-process default)
- `internal/services/markdown.go`
**Existing handlers touched:**
- `internal/handlers/handlers.go` — wire chat routes when `Chat != nil`.
**New handlers:**
- `internal/handlers/chat.go`
- `internal/handlers/chat_stream.go`
**Existing frontend touched:**
- `frontend/src/components/Sidebar.tsx`
- `frontend/src/projects-detail.tsx` (Chat tab)
- `frontend/src/client/sidebar.ts` (badge update)
- `frontend/src/i18n.ts` (~80 new keys)
- `frontend/src/build.ts` (chat bundle)
- `frontend/src/styles/global.css`
**New frontend:**
- `frontend/src/chat.tsx`
- `frontend/src/client/chat.ts`
- `frontend/src/client/markdown.ts` (or shared with views)
— end of design —

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
-- Reverse of 057_email_broadcasts.up.sql.
DROP TABLE IF EXISTS paliad.email_broadcasts;

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

View 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")
}

View File

@@ -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)

View File

@@ -45,6 +45,7 @@ type dbServices struct {
approval *services.ApprovalService
derivation *services.DerivationService
userView *services.UserViewService
broadcast *services.BroadcastService
}
var dbSvc *dbServices

View File

@@ -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) {

View 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 &lt;<a href="mailto:%s">%s</a>&gt;</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
`

View 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)
}
}

View 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{"&lt;script&gt;", "&lt;/script&gt;"},
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
}

View File

@@ -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))

View 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 "&lt;script&gt;" 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)
}

View File

@@ -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 {