Merge remote-tracking branch 'origin/main' into mai/kepler/inventor-profession-vs

This commit is contained in:
m
2026-05-07 22:00:26 +02:00
37 changed files with 6492 additions and 30 deletions

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,3 @@
-- t-paliad-146: Paliadin PoC — drop paliad.paliadin_turns.
DROP TABLE IF EXISTS paliad.paliadin_turns;

View File

@@ -0,0 +1,142 @@
-- t-paliad-146: Paliadin PoC — paliad.paliadin_turns.
--
-- Design: docs/design-paliadin-2026-05-07.md §0.5.6 (PoC variant).
--
-- Paliadin is the in-app conversational AI assistant. Phase 0 PoC runs on
-- m's laptop only (PALIADIN_ENABLED=false on prod default), backed by a
-- long-lived `claude` process inside a tmux session — not the Anthropic
-- Messages API. The PoC's load-bearing artefact is monitoring: every
-- turn writes a row here so m can decide via /admin/paliadin whether the
-- feature earns a production v1 build.
--
-- The PoC variant of this table stores the FULL prompt + response (no
-- redaction) because m is the only user, m is m's own compliance officer,
-- and the whole point is to read what was asked later. Production v1
-- swaps to hash-only storage; that's a separate migration.
--
-- Sections:
-- 1. CREATE paliad.paliadin_turns (with RLS).
-- 2. Indexes.
-- ============================================================================
-- 1. paliad.paliadin_turns
-- ============================================================================
CREATE TABLE paliad.paliadin_turns (
turn_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Who asked. FK to paliad.users (not auth.users) so deleting an auth
-- row leaves the audit trail intact via paliad.users.
user_id uuid NOT NULL REFERENCES paliad.users(id),
-- Browser session ID (opaque). Lets us group turns into "a single
-- conversation" without storing the full thread server-side.
session_id text NOT NULL,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz, -- NULL until end-of-turn
duration_ms int, -- finished_at - started_at
-- The user's prompt, verbatim. PoC scope only — production v1 stores
-- a redacted hash instead. See docs/design-paliadin-2026-05-07.md §3.3.
user_message text NOT NULL,
-- Claude's response, verbatim, with the [paliadin-meta] trailer
-- already stripped. The trailer's parsed fields land in `used_tools`,
-- `rows_seen`, `chip_count`, `classifier_tag` below.
response text,
-- Approximate token count (server-side word_count * 1.3). Claude Code
-- via tmux doesn't expose Anthropic's usage block, so this is a
-- coarse heuristic for the dashboard cost trend — not a billing
-- number.
response_tokens int,
-- Tool names Claude used during this turn, parsed from the
-- [paliadin-meta] trailer block ("used_tools: search_my_deadlines,
-- lookup_court"). Empty array means Claude didn't use any tool —
-- the load-bearing dashboard signal: high tool-use rate justifies
-- the data-grounding pitch in §8.1.
used_tools text[] NOT NULL DEFAULT '{}'::text[],
-- Row counts parallel to used_tools (e.g. "rows_seen: 3, 1" → {3, 1}).
-- Helps spot "tool ran but returned nothing" patterns.
rows_seen int[] NOT NULL DEFAULT '{}'::int[],
-- Number of action chips Claude embedded in the response.
chip_count int NOT NULL DEFAULT 0,
-- True if the user closed the SSE stream before Claude finished.
abandoned boolean NOT NULL DEFAULT false,
-- Which paliad page m was on when he asked. Empty when invoked from
-- /paliadin directly.
page_origin text,
-- Error code, NULL on success. Possible values:
-- tmux_unresponsive — couldn't write to the pane / pane died
-- pane_died — tmux window closed mid-turn
-- user_aborted — abandoned=true synonym, kept for query clarity
-- timeout — Claude didn't write the response file in time
-- prompt_disabled — PALIADIN_ENABLED=false at request time
error_code text,
-- Coarse self-classification by Claude itself in the [paliadin-meta]
-- trailer ("data" / "concept" / "navigation" / "meta" / "other").
-- Drives the use-case-shape histogram on /admin/paliadin.
classifier_tag text
);
-- ============================================================================
-- 2. Indexes
-- ============================================================================
-- Per-user timeline (the "my recent paliadin turns" query). Most rows for
-- the PoC will share user_id=m, so this index is mostly useful as a sort
-- helper.
CREATE INDEX paliadin_turns_user_started_idx
ON paliad.paliadin_turns(user_id, started_at DESC);
-- Global timeline for /admin/paliadin dashboard. Keeps the dashboard
-- queries (top-N recent turns, daily counts) on an index scan even as
-- the table grows.
CREATE INDEX paliadin_turns_started_idx
ON paliad.paliadin_turns(started_at DESC);
-- Histogram queries on classifier_tag. Tiny table at PoC scale; the
-- index pays for itself once we have weeks of data.
CREATE INDEX paliadin_turns_classifier_idx
ON paliad.paliadin_turns(classifier_tag, started_at DESC)
WHERE classifier_tag IS NOT NULL;
-- ============================================================================
-- 3. RLS
-- ============================================================================
ALTER TABLE paliad.paliadin_turns ENABLE ROW LEVEL SECURITY;
-- A user sees their own turns; global_admin sees all rows. The /admin/
-- paliadin dashboard runs under m (global_admin) and so sees the full
-- log. Other users would only see their own — though in PoC scope
-- there's only m, the policy is the production-shape from day one.
CREATE POLICY paliadin_turns_select
ON paliad.paliadin_turns FOR SELECT
USING (
user_id = auth.uid()
OR EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- Service-role (paliad backend) writes. Direct-auth INSERT is blocked.
-- Paliad runs with the service role today so the policy is inert in
-- practice; we still enable RLS so future direct-auth callers are gated.
CREATE POLICY paliadin_turns_insert_admin_only
ON paliad.paliadin_turns FOR INSERT
WITH CHECK (false);
CREATE POLICY paliadin_turns_update_admin_only
ON paliad.paliadin_turns FOR UPDATE
USING (false);
COMMENT ON TABLE paliad.paliadin_turns IS
'Per-turn audit log for Paliadin (in-app AI). PoC variant stores full prompt + response — production v1 will swap to hash-only. Powers /admin/paliadin dashboard. Design: docs/design-paliadin-2026-05-07.md §0.5.6.';

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,12 +65,22 @@ type Services struct {
Approval *services.ApprovalService
Derivation *services.DerivationService
UserView *services.UserViewService
Broadcast *services.BroadcastService
// Paliadin is wired only when PALIADIN_ENABLED=true at boot
// (PoC; m's laptop only). On prod it stays nil and all /paliadin*
// routes 404 because Register() skips registering them.
Paliadin *services.PaliadinService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
authClient = client
giteaToken = giteaAPIToken
if svc != nil && svc.Paliadin != nil {
paliadinSvc = svc.Paliadin
}
if svc != nil {
dbSvc = &dbServices{
projects: svc.Project,
@@ -102,6 +112,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 +352,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)
@@ -429,6 +450,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /views/{slug}", gateOnboarded(handleViewsShellPage))
}
// t-paliad-146 — Paliadin (PoC). Routes register unconditionally;
// the per-request handler gate (requirePaliadinOwner) returns 404
// for any authenticated user other than services.PaliadinOwnerEmail.
// No deploy-time toggle — the gate is in the code, not in the env.
protected.HandleFunc("GET /paliadin", gateOnboarded(handlePaliadinPage))
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset)
protected.HandleFunc("GET /admin/paliadin", gateOnboarded(handleAdminPaliadinPage))
protected.HandleFunc("GET /api/admin/paliadin/stats", handleAdminPaliadinStats)
protected.HandleFunc("GET /api/admin/paliadin/turns", handleAdminPaliadinTurns)
// Catch-all 404 — runs for any authenticated path that no more-specific
// pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from
// tests/smoke-auth-2026-04-25.md). Must be registered last on this mux.

View File

@@ -0,0 +1,351 @@
package handlers
// paliadin.go — HTTP/SSE handlers for the Paliadin PoC (t-paliad-146).
//
// Design: docs/design-paliadin-2026-05-07.md §0.5.
//
// Three user-facing surfaces:
// GET /paliadin — chat panel page shell
// POST /api/paliadin/turn — initiate a turn, returns {turn_id, sse_url}
// GET /api/paliadin/stream/{id} — SSE stream of the turn
// POST /api/paliadin/reset — /clear the conversation
// GET /admin/paliadin — monitoring dashboard (global_admin)
// GET /api/admin/paliadin/stats — stats JSON
// GET /api/admin/paliadin/turns — recent turns JSON
//
// Routes register only when the PaliadinService is wired (which only
// happens when PALIADIN_ENABLED=true at boot). On prod, where it's
// false by default, none of these URLs exist — they 404 like any
// unrouted path.
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// newDetachedContext returns a context with timeout that is independent
// of any incoming request — needed so the Claude-via-tmux turn isn't
// cancelled when the originating POST returns ahead of the SSE stream.
func newDetachedContext(timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), timeout)
}
// paliadinSvc is the live PaliadinService instance. nil when
// DATABASE_URL was unset (the service depends on the audit table).
// Set by Register() at boot.
var paliadinSvc *services.PaliadinService
// requirePaliadinOwner gates every paliadin handler to the single
// owner email (services.PaliadinOwnerEmail = m). Anyone else gets a
// 404 — the gate is a "this URL doesn't exist for you" pretence
// rather than a 403, so a curious colleague can't even confirm the
// route is wired.
func requirePaliadinOwner(w http.ResponseWriter, r *http.Request) bool {
if paliadinSvc == nil {
http.NotFound(w, r)
return false
}
uid, ok := requireUser(w, r)
if !ok {
return false
}
owner, err := paliadinSvc.IsOwner(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return false
}
if !owner {
http.NotFound(w, r)
return false
}
return true
}
// pendingTurns is an in-memory map of turn_id → result channel. The POST
// /api/paliadin/turn endpoint kicks off the work + writes a synthetic
// turn record; the GET /api/paliadin/stream/{id} endpoint reads from
// the channel + emits SSE events. PoC scope: single user, in-process
// state. Production v1 would use a Postgres-backed queue or pgnotify.
var (
pendingMu sync.Mutex
pendingTurns = map[uuid.UUID]chan turnEvent{}
)
// turnEvent is one SSE event for a turn-in-flight.
type turnEvent struct {
Kind string `json:"kind"` // meta | content | end | error
Data map[string]any `json:"data"`
}
// turnRequest is the JSON body of POST /api/paliadin/turn.
type turnRequest struct {
UserMessage string `json:"user_message"`
SessionID string `json:"session_id"`
PageOrigin string `json:"page_origin,omitempty"`
}
// turnResponse is what POST /api/paliadin/turn returns.
type turnResponse struct {
TurnID string `json:"turn_id"`
SSEURL string `json:"sse_url"`
}
// handlePaliadinPage serves the static /paliadin chat panel. Gated to
// the single Paliadin owner (m); every other authenticated user gets
// a 404 — the route effectively does not exist for them.
func handlePaliadinPage(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
http.ServeFile(w, r, "dist/paliadin.html")
}
// handleAdminPaliadinPage serves the /admin/paliadin monitoring page.
// Same owner gate — even other global_admins can't see this surface.
func handleAdminPaliadinPage(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
http.ServeFile(w, r, "dist/admin-paliadin.html")
}
// handlePaliadinTurn kicks off a turn and returns the SSE URL.
//
// We don't block here; the actual Claude work runs in a goroutine that
// pushes events into the per-turn channel. The client immediately opens
// EventSource on the returned URL and reads as the goroutine writes.
func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner
var req turnRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.UserMessage == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_message required"})
return
}
if req.SessionID == "" {
req.SessionID = uuid.New().String()
}
turnID := uuid.New()
ch := make(chan turnEvent, 16)
pendingMu.Lock()
pendingTurns[turnID] = ch
pendingMu.Unlock()
// Goroutine drives the actual Claude turn. We use a fresh context
// (not r.Context()) because the request is going to return as soon
// as we hand back the SSE URL — we don't want the whole turn to
// cancel when the POST completes.
go runPaliadinTurnAsync(turnID, services.TurnRequest{
UserID: uid,
SessionID: req.SessionID,
UserMessage: req.UserMessage,
PageOrigin: req.PageOrigin,
}, ch)
writeJSON(w, http.StatusOK, turnResponse{
TurnID: turnID.String(),
SSEURL: "/api/paliadin/stream/" + turnID.String(),
})
}
// runPaliadinTurnAsync executes the turn and writes events into ch.
// Uses a 2-minute hard timeout independently of the originating request.
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
defer func() {
// Drain + close. The SSE handler reads until the channel closes.
close(ch)
}()
// Send a meta event so the client can show "Paliadin denkt nach …"
send(ch, turnEvent{
Kind: "meta",
Data: map[string]any{
"turn_id": turnID.String(),
"started_at": time.Now().UTC().Format(time.RFC3339),
},
})
ctx, cancel := newDetachedContext(120 * time.Second)
defer cancel()
result, err := paliadinSvc.RunTurn(ctx, req)
if err != nil {
errCode := "upstream_error"
if errors.Is(err, services.ErrTmuxUnavailable) {
errCode = "tmux_unavailable"
}
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{"code": errCode, "message": err.Error()},
})
return
}
// One-shot content event with the full body. The frontend simulates
// streaming with a typewriter effect (cf. design §0.5.5: real
// chunked streaming would require Claude to write the response file
// progressively — out of PoC scope).
send(ch, turnEvent{
Kind: "content",
Data: map[string]any{"text": result.Response},
})
send(ch, turnEvent{
Kind: "end",
Data: map[string]any{
"turn_id": turnID.String(),
"used_tools": result.UsedTools,
"rows_seen": result.RowsSeen,
"chip_count": result.ChipCount,
"classifier_tag": result.ClassifierTag,
"duration_ms": result.DurationMS,
},
})
}
// handlePaliadinStream is the SSE endpoint the EventSource subscribes
// to. Reads from the per-turn channel + writes SSE-framed events.
func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
turnIDStr := r.PathValue("id")
turnID, err := uuid.Parse(turnIDStr)
if err != nil {
http.Error(w, "invalid turn_id", http.StatusBadRequest)
return
}
pendingMu.Lock()
ch, ok := pendingTurns[turnID]
pendingMu.Unlock()
if !ok {
http.Error(w, "unknown turn_id (already finished, or never started)", http.StatusNotFound)
return
}
// SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/Traefik buffering
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
// Heartbeat ticker keeps reverse proxies from reaping the connection.
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
// Client closed the connection — don't drain, leave the
// channel for the goroutine to finish into. Pending-turns
// cleanup happens after end/error events flush below.
return
case <-heartbeat.C:
fmt.Fprint(w, "event: ping\ndata: {}\n\n")
flusher.Flush()
case ev, more := <-ch:
if !more {
// Goroutine finished. Tidy up the pending-turns map.
pendingMu.Lock()
delete(pendingTurns, turnID)
pendingMu.Unlock()
return
}
payload, _ := json.Marshal(ev.Data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Kind, payload)
flusher.Flush()
if ev.Kind == "end" || ev.Kind == "error" {
// Don't return immediately — wait for the channel to
// close so the cleanup branch above runs and the client
// gets a clean EOF.
}
}
}
}
// handlePaliadinReset clears the Claude conversation context.
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
ctx, cancel := newDetachedContext(10 * time.Second)
defer cancel()
if err := paliadinSvc.ResetSession(ctx); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "reset failed: " + err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// =============================================================================
// /admin/paliadin — monitoring dashboard.
// =============================================================================
// handleAdminPaliadinStats returns the aggregate stats for the dashboard.
func handleAdminPaliadinStats(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r)
stats, err := paliadinSvc.Stats(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, stats)
}
// handleAdminPaliadinTurns returns the most recent turn rows.
func handleAdminPaliadinTurns(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r)
turns, err := paliadinSvc.ListRecentTurns(r.Context(), uid, 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, turns)
}
// =============================================================================
// helpers.
// =============================================================================
// send pushes an event onto the channel without blocking — drops on
// overflow. PoC scope: 16-deep buffer, single subscriber, very unlikely
// to overflow even with the slow Claude-via-tmux path.
func send(ch chan<- turnEvent, ev turnEvent) {
select {
case ch <- ev:
default:
// Channel full — drop. Logged by the SSE consumer's gap (it
// keeps reading; only "end"/"error" matter for completion).
}
}

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

@@ -73,6 +73,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

@@ -0,0 +1,757 @@
package services
// PaliadinService — Phase 0 PoC of the in-app AI buddy (t-paliad-146).
//
// Design: docs/design-paliadin-2026-05-07.md §0.5 (PoC track).
//
// Architecture: a long-lived `claude` process inside a tmux session.
// Prompts go in via `tmux send-keys -l`; responses come back via a
// per-turn file the system prompt instructs Claude to write
// (Write(/tmp/paliadin/{turn_id}.txt)). The service polls that file,
// strips the [paliadin-meta] trailer block, parses the metadata, writes
// an audit row, and emits the response back to the SSE handler.
//
// The architecture is lifted (with adaptation to Go) from
// ~/dev/mVoice/server.py:250-380, which has been driving the goldi voice
// surface in production since 2026-Q1.
//
// PoC ONLY runs on m's laptop (PALIADIN_ENABLED=false on prod default).
// Hardcoded single-user, single-tmux-window scope. Do not attempt to
// deploy this to the Dokploy container — there is no `claude` CLI there.
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// PaliadinOwnerEmail is the only account allowed to use the Paliadin
// PoC. Hardcoded — by design — so the gate cannot be flipped via a
// deploy env var. PoC ships at this scope; multi-user opens up only
// when production v1 lands behind its own auth model.
//
// Matches the seed in migration 023 (m's job_title row). If m's email
// ever rotates, this constant must rotate with it; there is no other
// path to enabling Paliadin.
const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
// PaliadinService manages the tmux-claude PoC.
type PaliadinService struct {
db *sqlx.DB
tmuxSession string
responseDir string
users *UserService
// Cached pane target ("session:window-idx") once the voice window is
// either discovered or created. Reset to "" if the pane dies.
mu sync.Mutex
paneTarget string
// Single in-flight turn at a time. PoC scope — one user (m), serialised
// by a session-level mutex. Production v1 would queue / fan out.
turnMu sync.Mutex
}
// IsOwner returns true when the given user_id corresponds to m's
// account (the only Paliadin PoC user). Resolves via paliad.users.email
// rather than caching a UUID so a DB rebuild that reassigns auth UUIDs
// doesn't strand the gate.
//
// Returns (false, nil) for any other user — including unknown UUIDs and
// users without an email row. Errors only on DB failure.
func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
var email string
err := s.db.QueryRowxContext(ctx,
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("paliadin: lookup owner: %w", err)
}
return strings.EqualFold(email, PaliadinOwnerEmail), nil
}
// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true.
func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService {
if tmuxSession == "" {
tmuxSession = "paliad-paliadin"
}
if responseDir == "" {
responseDir = "/tmp/paliadin"
}
return &PaliadinService{
db: db,
tmuxSession: tmuxSession,
responseDir: responseDir,
users: users,
}
}
// PaliadinTurn is the audit row.
type PaliadinTurn struct {
TurnID uuid.UUID `db:"turn_id" json:"turn_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
SessionID string `db:"session_id" json:"session_id"`
StartedAt time.Time `db:"started_at" json:"started_at"`
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
UserMessage string `db:"user_message" json:"user_message"`
Response *string `db:"response" json:"response,omitempty"`
ResponseTokens *int `db:"response_tokens" json:"response_tokens,omitempty"`
UsedTools pq.StringArray `db:"used_tools" json:"used_tools"`
RowsSeen pq.Int64Array `db:"rows_seen" json:"rows_seen"`
ChipCount int `db:"chip_count" json:"chip_count"`
Abandoned bool `db:"abandoned" json:"abandoned"`
PageOrigin *string `db:"page_origin" json:"page_origin,omitempty"`
ErrorCode *string `db:"error_code" json:"error_code,omitempty"`
ClassifierTag *string `db:"classifier_tag" json:"classifier_tag,omitempty"`
}
// TurnRequest is what the handler passes to RunTurn.
type TurnRequest struct {
UserID uuid.UUID
SessionID string
UserMessage string
PageOrigin string // empty when unknown
}
// TurnResult is what RunTurn returns to the handler.
type TurnResult struct {
TurnID uuid.UUID
Response string // body without [paliadin-meta] trailer
UsedTools []string
RowsSeen []int
ChipCount int
ClassifierTag string
DurationMS int
}
// ErrPaliadinDisabled is the canonical "service is wired but turned off"
// signal. Handlers map it to 503.
var ErrPaliadinDisabled = errors.New("paliadin: disabled")
// ErrTmuxUnavailable indicates we couldn't talk to tmux (binary missing,
// session unreachable, etc.). Handlers map it to 503 with a hint.
var ErrTmuxUnavailable = errors.New("paliadin: tmux unavailable")
// RunTurn executes one full Q&A round. Blocks until Claude has written
// the response file or we time out (default 60 s). Writes the audit row
// in both success + error paths.
//
// PoC: serialised. The package-level turnMu enforces "one at a time".
// m is the only user, so this is fine.
func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
s.turnMu.Lock()
defer s.turnMu.Unlock()
turnID := uuid.New()
startedAt := time.Now().UTC()
// Audit row — written *first* so a crash mid-turn still leaves traces.
if err := s.insertTurnRow(ctx, &PaliadinTurn{
TurnID: turnID,
UserID: req.UserID,
SessionID: req.SessionID,
StartedAt: startedAt,
UserMessage: req.UserMessage,
PageOrigin: optionalString(req.PageOrigin),
}); err != nil {
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
}
// Ensure tmux session + Claude pane.
target, err := s.ensurePane(ctx)
if err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
return nil, fmt.Errorf("%w: %v", ErrTmuxUnavailable, err)
}
// Make sure the response dir exists.
if err := os.MkdirAll(s.responseDir, 0o755); err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
}
// Send the framed prompt. The system prompt teaches Claude to react
// to the [PALIADIN:turn_id] envelope by writing the response file.
envelope := fmt.Sprintf("[PALIADIN:%s] %s", turnID, sanitiseForTmux(req.UserMessage))
if err := s.sendToPane(ctx, target, envelope); err != nil {
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
return nil, fmt.Errorf("%w: send prompt: %v", ErrTmuxUnavailable, err)
}
// Poll for the response file. Fixed 60 s timeout; abort early if the
// caller's context is cancelled (e.g. user clicked Stop).
respPath := filepath.Join(s.responseDir, turnID.String()+".txt")
body, err := s.pollForResponse(ctx, respPath, 60*time.Second)
if err != nil {
ec := "timeout"
if errors.Is(err, context.Canceled) {
ec = "user_aborted"
}
_ = s.markTurnAbandonedOrError(ctx, turnID, ec, ec == "user_aborted")
return nil, err
}
// Strip + parse the [paliadin-meta] trailer. Best-effort: the prompt
// instructs Claude to emit it but the PoC's monitoring is precisely
// what tells us how reliable that is in practice.
cleanBody, meta := splitTrailer(body)
tokens := approxTokenCount(cleanBody)
chipCount := countChips(cleanBody)
finished := time.Now().UTC()
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
// Write the result back into the audit row.
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, meta, chipCount); err != nil {
log.Printf("paliadin: complete turn %s: %v", turnID, err)
// Don't fail the user-facing request on audit-row write errors —
// the response is real even if the bookkeeping is broken.
}
return &TurnResult{
TurnID: turnID,
Response: cleanBody,
UsedTools: meta.UsedTools,
RowsSeen: meta.RowsSeen,
ChipCount: chipCount,
ClassifierTag: meta.ClassifierTag,
DurationMS: durationMS,
}, nil
}
// ResetSession sends `/clear` to the Claude pane so the next turn starts
// from a clean conversation. Used by the "New conversation" button.
func (s *PaliadinService) ResetSession(ctx context.Context) error {
s.mu.Lock()
target := s.paneTarget
s.mu.Unlock()
if target == "" {
// Nothing to reset; the next RunTurn will spin up a fresh pane.
return nil
}
if err := s.sendToPane(ctx, target, "/clear"); err != nil {
return err
}
return nil
}
// ListRecentTurns reads the last N turns visible to the caller.
// global_admin sees everything; everyone else sees their own.
func (s *PaliadinService) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
out := make([]PaliadinTurn, 0, limit)
q := `
SELECT turn_id, user_id, session_id, started_at, finished_at, duration_ms,
user_message, response, response_tokens, used_tools, rows_seen,
chip_count, abandoned, page_origin, error_code, classifier_tag
FROM paliad.paliadin_turns
WHERE user_id = $1
OR EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = $1 AND u.global_role = 'global_admin')
ORDER BY started_at DESC
LIMIT $2
`
if err := s.db.SelectContext(ctx, &out, q, callerID, limit); err != nil {
return nil, fmt.Errorf("paliadin: list turns: %w", err)
}
return out, nil
}
// PaliadinStats is the aggregate view shown on /admin/paliadin.
type PaliadinStats struct {
TotalTurns int `json:"total_turns"`
TurnsLast7Days int `json:"turns_last_7_days"`
MedianDurationMS int `json:"median_duration_ms"`
P90DurationMS int `json:"p90_duration_ms"`
ToolUseRate float64 `json:"tool_use_rate"` // 0..1
AbandonRate float64 `json:"abandon_rate"` // 0..1
ByClassifier map[string]int `json:"by_classifier"` // tag → count
DailyCounts []PaliadinDailyCount `json:"daily_counts"` // last 30 days
TopPrompts []PaliadinPromptCount `json:"top_prompts"` // most-frequent normalised prompts
}
type PaliadinDailyCount struct {
Day string `db:"day" json:"day"` // YYYY-MM-DD
Count int `db:"count" json:"count"`
}
type PaliadinPromptCount struct {
Prompt string `db:"prompt" json:"prompt"`
Count int `db:"count" json:"count"`
}
// Stats computes the dashboard aggregate. global_admin sees everything;
// everyone else sees their own slice (PoC has only m, but the policy
// matches RLS on the table).
func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) {
stats := &PaliadinStats{
ByClassifier: map[string]int{},
DailyCounts: []PaliadinDailyCount{},
TopPrompts: []PaliadinPromptCount{},
}
// Visibility predicate: caller's own rows OR all rows if global_admin.
visible := `(user_id = $1 OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin'))`
// Total + 7-day count.
if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(`
SELECT COUNT(*),
COUNT(*) FILTER (WHERE started_at >= now() - interval '7 days')
FROM paliad.paliadin_turns
WHERE %s
`, visible), callerID).Scan(&stats.TotalTurns, &stats.TurnsLast7Days); err != nil {
return nil, fmt.Errorf("paliadin: stats totals: %w", err)
}
if stats.TotalTurns == 0 {
return stats, nil
}
// Duration percentiles. Skip rows still in flight (duration_ms NULL).
if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(`
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms), 0)::int,
COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY duration_ms), 0)::int
FROM paliad.paliadin_turns
WHERE %s AND duration_ms IS NOT NULL
`, visible), callerID).Scan(&stats.MedianDurationMS, &stats.P90DurationMS); err != nil {
return nil, fmt.Errorf("paliadin: stats percentiles: %w", err)
}
// Tool-use + abandon rates.
var toolUsedTurns, abandonedTurns int
if err := s.db.QueryRowxContext(ctx, fmt.Sprintf(`
SELECT COUNT(*) FILTER (WHERE array_length(used_tools, 1) > 0),
COUNT(*) FILTER (WHERE abandoned = true)
FROM paliad.paliadin_turns
WHERE %s
`, visible), callerID).Scan(&toolUsedTurns, &abandonedTurns); err != nil {
return nil, fmt.Errorf("paliadin: stats rates: %w", err)
}
stats.ToolUseRate = float64(toolUsedTurns) / float64(stats.TotalTurns)
stats.AbandonRate = float64(abandonedTurns) / float64(stats.TotalTurns)
// Histogram by classifier_tag.
rows, err := s.db.QueryxContext(ctx, fmt.Sprintf(`
SELECT COALESCE(classifier_tag, 'untagged') AS tag, COUNT(*) AS n
FROM paliad.paliadin_turns
WHERE %s
GROUP BY tag
`, visible), callerID)
if err != nil {
return nil, fmt.Errorf("paliadin: stats classifier: %w", err)
}
defer rows.Close()
for rows.Next() {
var tag string
var n int
if err := rows.Scan(&tag, &n); err != nil {
return nil, err
}
stats.ByClassifier[tag] = n
}
// Daily counts (last 30 days).
if err := s.db.SelectContext(ctx, &stats.DailyCounts, fmt.Sprintf(`
SELECT to_char(date_trunc('day', started_at), 'YYYY-MM-DD') AS day,
COUNT(*) AS count
FROM paliad.paliadin_turns
WHERE %s
AND started_at >= now() - interval '30 days'
GROUP BY day
ORDER BY day ASC
`, visible), callerID); err != nil {
return nil, fmt.Errorf("paliadin: stats daily: %w", err)
}
// Top prompts (normalised: lowercase + collapse whitespace + trim).
if err := s.db.SelectContext(ctx, &stats.TopPrompts, fmt.Sprintf(`
SELECT trim(regexp_replace(lower(user_message), '\s+', ' ', 'g')) AS prompt,
COUNT(*) AS count
FROM paliad.paliadin_turns
WHERE %s
GROUP BY prompt
ORDER BY count DESC, prompt ASC
LIMIT 10
`, visible), callerID); err != nil {
return nil, fmt.Errorf("paliadin: stats top prompts: %w", err)
}
return stats, nil
}
// =============================================================================
// tmux orchestration — adapted from mVoice/server.py:250-380.
// =============================================================================
// ensurePane returns the tmux target ("session:window-idx") of the live
// Claude pane, creating both session and window if missing.
func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Cheap path: if we have a cached target and it's still alive, reuse.
if s.paneTarget != "" && s.paneAlive(ctx, s.paneTarget) {
return s.paneTarget, nil
}
// Ensure session.
if err := runTmux(ctx, "has-session", "-t", s.tmuxSession); err != nil {
// Create detached.
if err := runTmux(ctx, "new-session", "-d", "-s", s.tmuxSession); err != nil {
return "", fmt.Errorf("new-session: %w", err)
}
}
// Look for an existing window tagged with @paliadin-scope=chat.
if existing := s.findChatWindow(ctx); existing != "" {
s.paneTarget = existing
return existing, nil
}
// No window — create one running `claude` in a fresh pane. Must be
// interactive: claude reads stdin, so the tmux pane behaves like a
// terminal. We use `new-window -P -F` to print the new index back.
out, err := runTmuxOut(ctx, "new-window", "-t", s.tmuxSession,
"-n", "claude-paliadin",
"-P", "-F", "#{window_index}",
"claude")
if err != nil {
return "", fmt.Errorf("new-window claude: %w", err)
}
idx := strings.TrimSpace(out)
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
// Wait for Claude's prompt indicator. Claude Code's interactive
// prompt rendering varies but always settles into a state where the
// pane has a "" prompt glyph or "│" sidebar visible. We give it
// 30 s, which is generous.
if err := s.waitForPaneReady(ctx, target, 30*time.Second); err != nil {
return "", fmt.Errorf("wait-for-ready: %w", err)
}
// Tag the window so a re-discover next boot finds it.
_ = runTmux(ctx, "set-window-option", "-t", target, "@paliadin-scope", "chat")
_ = runTmux(ctx, "set-window-option", "-t", target, "@fix-name", "claude-paliadin")
// Send the bootstrap system prompt so Claude knows who it is and how
// to reply (write to the per-turn file with [paliadin-meta] trailer).
if err := s.sendToPane(ctx, target, paliadinSystemPrompt(s.responseDir)); err != nil {
return "", fmt.Errorf("send system prompt: %w", err)
}
// Give Claude a moment to absorb the system prompt before turns flow.
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(2 * time.Second):
}
s.paneTarget = target
return target, nil
}
func (s *PaliadinService) findChatWindow(ctx context.Context) string {
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
"-F", "#{window_index}")
if err != nil {
return ""
}
for _, idx := range strings.Fields(out) {
target := fmt.Sprintf("%s:%s", s.tmuxSession, idx)
scope, err := runTmuxOut(ctx, "show-window-option",
"-t", target, "-v", "@paliadin-scope")
if err == nil && strings.TrimSpace(scope) == "chat" {
return target
}
}
return ""
}
func (s *PaliadinService) paneAlive(ctx context.Context, target string) bool {
if err := runTmux(ctx, "has-session", "-t", target); err != nil {
return false
}
return true
}
func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
out, err := runTmuxOut(ctx, "capture-pane", "-t", target, "-p")
if err == nil && (strings.Contains(out, "") || strings.Contains(out, "│")) {
return nil
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("pane %s not ready within %s", target, timeout)
}
func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) error {
// `-l` sends the message literally (no key parsing) — necessary so
// our prompt's special characters don't get interpreted.
if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil {
return err
}
// Trailing Enter. tmux send-keys treats "Enter" as a special key name.
if err := runTmux(ctx, "send-keys", "-t", target, "Enter"); err != nil {
return err
}
return nil
}
// pollForResponse waits for the response file to materialise. Returns
// the file's content (and removes the file). Treats stale files (left
// over from earlier turns) as a non-event — the file existing without a
// fresh mtime is a corner case the caller already de-duplicates by
// having a unique turn_id per request.
func (s *PaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
data, err := os.ReadFile(path)
if err == nil && len(data) > 0 {
// Brief settle delay so we don't read mid-flush.
time.Sleep(50 * time.Millisecond)
data, _ = os.ReadFile(path)
_ = os.Remove(path)
return string(data), nil
}
time.Sleep(200 * time.Millisecond)
}
return "", fmt.Errorf("paliadin: response timeout after %s", timeout)
}
// =============================================================================
// shell / tmux helpers.
// =============================================================================
// runTmux runs `tmux <args...>`. Discards output. Returns error if tmux
// returns non-zero.
func runTmux(ctx context.Context, args ...string) error {
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
cmd := exec.CommandContext(c, "tmux", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tmux %s: %w (stderr: %s)", strings.Join(args, " "), err, stderr.String())
}
return nil
}
// runTmuxOut runs `tmux <args...>` and returns stdout. Useful for
// capture-pane / list-windows / show-window-option.
func runTmuxOut(ctx context.Context, args ...string) (string, error) {
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
cmd := exec.CommandContext(c, "tmux", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("tmux %s: %w (stderr: %s)", strings.Join(args, " "), err, stderr.String())
}
return stdout.String(), nil
}
// sanitiseForTmux removes control sequences that would confuse the pane.
// `tmux send-keys -l` sends literally, but stray newlines inside the
// message would split it across multiple "send" actions, breaking the
// turn envelope.
func sanitiseForTmux(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
// Cap length: a runaway prompt is a footgun.
const maxLen = 8000
if len(s) > maxLen {
s = s[:maxLen] + " […truncated]"
}
return s
}
// =============================================================================
// trailer parsing.
// =============================================================================
// trailerMeta is what we extract from the [paliadin-meta]…[/paliadin-meta]
// block at the end of Claude's response. Best-effort: missing fields
// default to zero values.
type trailerMeta struct {
UsedTools []string
RowsSeen []int
ClassifierTag string
}
var trailerRE = regexp.MustCompile(`(?s)\n*---\s*\n+\[paliadin-meta\]\s*\n(.+?)\n\[/paliadin-meta\]\s*$`)
// splitTrailer separates the meta block from the body. If no trailer is
// present, the entire input is returned as the body.
func splitTrailer(body string) (string, trailerMeta) {
body = strings.TrimRight(body, " \t\n\r")
m := trailerRE.FindStringSubmatchIndex(body)
if m == nil {
return body, trailerMeta{}
}
cleanBody := strings.TrimRight(body[:m[0]], " \t\n\r")
metaText := body[m[2]:m[3]]
return cleanBody, parseTrailer(metaText)
}
func parseTrailer(text string) trailerMeta {
out := trailerMeta{}
for _, line := range strings.Split(text, "\n") {
k, v, ok := splitFirst(strings.TrimSpace(line), ":")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch strings.ToLower(strings.TrimSpace(k)) {
case "used_tools":
for _, t := range strings.Split(v, ",") {
t = strings.TrimSpace(t)
if t != "" {
out.UsedTools = append(out.UsedTools, t)
}
}
case "rows_seen":
for _, t := range strings.Split(v, ",") {
n, err := strconv.Atoi(strings.TrimSpace(t))
if err == nil {
out.RowsSeen = append(out.RowsSeen, n)
}
}
case "classifier_tag":
out.ClassifierTag = v
}
}
return out
}
func splitFirst(s, sep string) (string, string, bool) {
i := strings.Index(s, sep)
if i < 0 {
return "", "", false
}
return s[:i], s[i+len(sep):], true
}
// approxTokenCount is a coarse word-count × 1.3 heuristic. Real token
// counts aren't exposed by Claude Code via tmux; this is just for the
// dashboard's cost-trend sense.
func approxTokenCount(s string) int {
if s == "" {
return 0
}
words := strings.Fields(s)
return int(float64(len(words)) * 1.3)
}
// countChips matches the `[#deadline-OPEN:…]`, `[#projekt-OPEN:…]`,
// `[chip:…]` markers the system prompt asks Claude to embed. PoC's
// frontend renders these as buttons; for the audit log we only need
// the count.
var chipRE = regexp.MustCompile(`\[(?:#[a-z]+-OPEN:[A-Za-z0-9\-_]+|chip:[a-z]+:[^\]]+)\]`)
func countChips(s string) int {
return len(chipRE.FindAllString(s, -1))
}
// =============================================================================
// audit-row writers.
// =============================================================================
func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) error {
q := `
INSERT INTO paliad.paliadin_turns (
turn_id, user_id, session_id, started_at, user_message, page_origin
) VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := s.db.ExecContext(ctx, q,
t.TurnID, t.UserID, t.SessionID, t.StartedAt, t.UserMessage, t.PageOrigin)
return err
}
func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID,
finishedAt time.Time, durationMS int, response string, tokens int,
meta trailerMeta, chipCount int) error {
rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen))
for _, n := range meta.RowsSeen {
rowsSeen = append(rowsSeen, int64(n))
}
q := `
UPDATE paliad.paliadin_turns
SET finished_at = $2,
duration_ms = $3,
response = $4,
response_tokens = $5,
used_tools = $6,
rows_seen = $7,
chip_count = $8,
classifier_tag = $9
WHERE turn_id = $1
`
_, err := s.db.ExecContext(ctx, q,
turnID, finishedAt, durationMS, response, tokens,
pq.StringArray(meta.UsedTools), rowsSeen, chipCount,
optionalString(meta.ClassifierTag))
return err
}
func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error {
finished := time.Now().UTC()
q := `
UPDATE paliad.paliadin_turns
SET finished_at = $2, error_code = $3
WHERE turn_id = $1 AND finished_at IS NULL
`
_, err := s.db.ExecContext(ctx, q, turnID, finished, code)
return err
}
func (s *PaliadinService) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error {
finished := time.Now().UTC()
q := `
UPDATE paliad.paliadin_turns
SET finished_at = $2, error_code = $3, abandoned = $4
WHERE turn_id = $1 AND finished_at IS NULL
`
_, err := s.db.ExecContext(ctx, q, turnID, finished, code, abandoned)
return err
}
func optionalString(s string) *string {
if s == "" {
return nil
}
return &s
}
// Compile-time type guards (catches sql.ErrNoRows shifts).
var _ error = sql.ErrNoRows

View File

@@ -0,0 +1,269 @@
package services
// Paliadin system prompt — Phase 0 PoC.
//
// This is the bootstrap message sent to the long-lived `claude` pane
// once, right after the pane is created. It defines who Paliadin is,
// how to reply (write to the per-turn response file, emit a
// [paliadin-meta] trailer block), what SQL to run, and how visibility
// is enforced.
//
// Design: docs/design-paliadin-2026-05-07.md §0.5.3 + §2.2.1.
//
// Conventions:
// - The prompt MUST end with the response-file write rule, since that
// is the contract the Go service polls on.
// - SQL recipes MUST always include the visibility predicate
// (paliad.can_see_project) on any project-scoped query — even
// though m's global_role=global_admin technically lets him see
// everything, we keep the muscle memory consistent with the
// production-v1 design.
// - The trailer format is stable; the trailer parser in paliadin.go
// must be kept in sync.
import "strings"
// paliadinSystemPrompt returns the full bootstrap message for a fresh
// Claude pane. The response_dir argument is the path where Claude must
// write its per-turn response files.
//
// Built via concatenation rather than fmt.Sprintf because the prompt
// contains German genitive apostrophes ("m's") that Sprintf misreads as
// format verbs.
func paliadinSystemPrompt(responseDir string) string {
return strings.TrimSpace(`
Du bist Paliadin — der eingebaute KI-Assistent in Paliad, m's Patentpraxis-Plattform. Du hilfst m bei seiner täglichen Arbeit: Akten finden, Fristen prüfen, Begriffe erklären, Gerichte nachschlagen, UPC-Rechtsprechung recherchieren.
# Persönlichkeit
- Direkt, kompetent, juristisch präzise. Keine Floskeln.
- Sprich wie ein Patentanwalts-Kollege mit zehn Jahren UPC-Erfahrung — nicht wie ein generischer Chatbot.
- Belege jede konkrete Aussage mit einem Tool-Call oder einer Zitat-Quelle. Niemals raten.
- Antworte standardmäßig auf Deutsch (m's Arbeitssprache). Wenn m auf Englisch fragt, antworte auf Englisch.
- Keine Emojis, keine "Ich helfe dir gerne!"-Phrasen.
# Antwort-Protokoll (KRITISCH)
Jede Anfrage von m kommt im Format: ` + "`[PALIADIN:turn_id] <Frage>`" + `
Sobald du die turn_id liest:
1. Recherchiere mit deinen Tools (siehe SQL-Rezepte unten).
2. Formuliere eine knappe, faktenbasierte Antwort in Markdown.
3. Schreibe die Antwort in eine Datei: ` + "`Write(" + responseDir + "/{turn_id}.txt)`" + `
4. WICHTIG: Schreib SOFORT, sobald du die Antwort hast. Das System wartet (Timeout: 60s).
5. Häng am Ende des Antworttextes IMMER einen [paliadin-meta]-Block an — sonst weiß das System nicht, was du gemacht hast.
# Trailer-Format (PFLICHT am Ende jeder Antwort)
Trenne den Block mit einer Leerzeile + ---, dann:
[paliadin-meta]
used_tools: <komma-separierte Tool-Namen, leer wenn keiner verwendet>
rows_seen: <komma-separierte Zeilen-Counts, parallel zu used_tools>
classifier_tag: <data | concept | navigation | meta | other>
[/paliadin-meta]
Beispiel:
[paliadin-meta]
used_tools: search_my_deadlines, lookup_court
rows_seen: 3, 1
classifier_tag: data
[/paliadin-meta]
Die classifier_tag-Werte:
- ` + "`data`" + ` — m fragt nach seinen eigenen Daten ("welche Frist…", "auf welchem Projekt…")
- ` + "`concept`" + ` — m fragt nach einem juristischen Begriff/Verfahren ("was ist Klageerwiderung?")
- ` + "`navigation`" + ` — m sucht eine Seite/Funktion in Paliad ("wie öffne ich…")
- ` + "`meta`" + ` — Frage über Paliadin selbst, oder Smalltalk
- ` + "`other`" + ` — alles andere (Recherche, Web-Wissen)
# Action-Chips (optional, aber gerne nutzen)
Wenn du eine konkrete Folge-Aktion anbieten kannst, embed einen Chip-Marker direkt in den Antworttext. Das Frontend rendert ihn als anklickbaren Button:
- ` + "`[#deadline-OPEN:c47bd2-...]`" + ` — öffnet die Fristen-Detailseite
- ` + "`[#projekt-OPEN:slug-x]`" + ` — öffnet die Projekt-Detailseite
- ` + "`[chip:nav:/projects/abc-123]`" + ` — beliebige Navigation
- ` + "`[chip:filter:status=pending&due=this_week]`" + ` — gefilterter Inbox-Link
Verwende NUR IDs/Slugs, die du tatsächlich aus einem Tool-Call zurückbekommen hast. Niemals erfinden.
# Hard Rules
1. **Keine Erfindungen.** Wenn ein Tool keine Daten liefert, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
2. **Jede konkrete Aussage über m's eigene Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
3. **Schreibe nichts in die DB.** Du bist read-only. Wenn m etwas ändern will, sag ihm wo in Paliad.
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS ` + "`paliad.can_see_project(project_id)`" + ` enthalten. Konsistenz mit der späteren Multi-User-Version.
5. **Nicht über die Daten anderer User spekulieren**, selbst wenn m sie namentlich erwähnt — frag nach Projekt-ID/Slug.
# SQL-Rezepte
Du hast Zugriff auf zwei Datenquellen über das Supabase MCP (mcp__supabase__execute_sql):
- ` + "`paliad.*`" + ` — m's Patent-Praxis-Daten (Projekte, Fristen, Termine, Parteien, Gerichte, Glossar, Deadline-Rules)
- ` + "`data.*`" + ` — youpc.org UPC-Rechtsprechung (Urteile, Headnotes, Knowledge Graph) — selbe physische DB!
## 1. whats_on_my_plate — m's Dashboard-Übersicht
` + "```sql" + `
SELECT
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending' AND d.due_date = current_date) AS today,
(SELECT count(*) FROM paliad.deadlines d
WHERE paliad.can_see_project(d.project_id)
AND d.status = 'pending'
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
(SELECT count(*) FROM paliad.appointments a
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at::date = current_date) AS appointments_today;
` + "```" + `
## 2. list_my_projects
` + "```sql" + `
SELECT id, kind, label, status, parent_id, path
FROM paliad.projects
WHERE paliad.can_see_project(id)
AND status = 'active'
ORDER BY path
LIMIT 25;
` + "```" + `
## 3. get_project_detail (gegeben slug oder id)
` + "```sql" + `
SELECT p.*,
(SELECT json_agg(d ORDER BY d.due_date)
FROM paliad.deadlines d WHERE d.project_id = p.id
AND paliad.can_see_project(d.project_id)) AS deadlines,
(SELECT json_agg(a ORDER BY a.start_at)
FROM paliad.appointments a WHERE a.project_id = p.id
AND paliad.can_see_project(a.project_id)) AS appointments,
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
FROM paliad.projects p
WHERE paliad.can_see_project(p.id)
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
LIMIT 1;
` + "```" + `
## 4. search_my_deadlines (status / Datum / Projekt)
` + "```sql" + `
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
FROM paliad.deadlines d
JOIN paliad.projects p ON p.id = d.project_id
WHERE paliad.can_see_project(d.project_id)
AND ($status::text IS NULL OR d.status = $status)
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
ORDER BY d.due_date ASC
LIMIT 25;
` + "```" + `
## 5. list_my_appointments (Zeitfenster)
` + "```sql" + `
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
FROM paliad.appointments a
LEFT JOIN paliad.projects p ON p.id = a.project_id
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
AND a.start_at >= $from
AND a.start_at <= $to
ORDER BY a.start_at ASC
LIMIT 25;
` + "```" + `
## 6. lookup_court (Gerichtskatalog — firm-wide reference)
` + "```sql" + `
SELECT c.slug, c.name, c.country, c.kind, c.address
FROM paliad.courts c
WHERE c.name ILIKE '%' || $q || '%'
OR c.slug ILIKE '%' || $q || '%'
ORDER BY similarity(c.name, $q) DESC
LIMIT 10;
` + "```" + `
## 7. lookup_glossary_term (Patent-Glossar, DE+EN)
` + "```sql" + `
-- Hinweis: Glossar ist statisch in internal/handlers/glossary.go.
-- Der Service lädt JSON beim Boot. Wenn du einen Begriff suchst, frag mich
-- direkt im Chat — m hat den Glossar-Volltext im Kopf, oder ich kann ihn
-- aus paliad.deadline_rules.legal_source ableiten.
` + "```" + `
## 8. lookup_deadline_rule (Fristenrechner-Konzepte)
` + "```sql" + `
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
FROM paliad.deadline_rules r
WHERE r.concept_label ILIKE '%' || $q || '%'
OR r.rule_code ILIKE '%' || $q || '%'
OR r.legal_source ILIKE '%' || $q || '%'
ORDER BY similarity(r.concept_label, $q) DESC
LIMIT 5;
` + "```" + `
## 9. lookup_youpc_case (UPC-Rechtsprechung — cross-schema!)
` + "```sql" + `
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
j.proceedings_type, j.decision_date, j.headnote_summary,
j.tags
FROM data.judgments j
WHERE j.upc_number ILIKE '%' || $q || '%'
OR j.headnote_summary ILIKE '%' || $q || '%'
OR j.tags::text ILIKE '%' || $q || '%'
ORDER BY j.decision_date DESC
LIMIT 5;
` + "```" + `
Volltext eines Urteils (wenn m fragt "was steht in dem Urteil?"):
` + "```sql" + `
SELECT content
FROM data.judgment_markdown_content
WHERE judgment_node_id = <node_id>
ORDER BY chunk_index
LIMIT 1;
` + "```" + `
# Beispiel-Antwort
m fragt: ` + "`[PALIADIN:abc-123] welche fristen sind diese woche fällig?`" + `
Du machst:
1. ` + "`mcp__supabase__execute_sql`" + ` mit Rezept #4 (search_my_deadlines), $status='pending', $due_after=current_date, $due_before=current_date+7
2. Du bekommst z.B. 3 Zeilen zurück.
3. Du schreibst:
` + "```" + `
Write("/tmp/paliadin/abc-123.txt", """
Diese Woche stehen 3 Fristen an:
- **16.05.** Klageerwiderung auf Müller v. Acme [#deadline-OPEN:c47bd2-1] — UPC LD München
- **17.05.** Replik auf BMW v. Daimler [#deadline-OPEN:e92a01-3]
- **20.05.** Wiedereinsetzungsantrag auf Bosch-Patent [#deadline-OPEN:f31b09-7]
Willst du eine davon im Detail anschauen?
---
[paliadin-meta]
used_tools: search_my_deadlines
rows_seen: 3
classifier_tag: data
[/paliadin-meta]
""")
` + "```" + `
# Wichtig
Der erste turn-Envelope, den du nach diesem System-Prompt bekommst, ist eine richtige m-Anfrage. Antworte gemäß Protokoll. Bei der allerersten Anfrage darfst du dich kurz vorstellen ("Hi m, ich bin Paliadin — bereit."), danach normaler Modus.
`)
}

View File

@@ -0,0 +1,165 @@
package services
import (
"strings"
"testing"
)
// Tests for the PoC paliadin trailer parser. The parser is load-bearing:
// it's how the dashboard learns which tools Claude used, how many rows
// each returned, and how Claude classified the question. Wrong parsing
// = silently broken monitoring.
func TestSplitTrailer_HappyPath(t *testing.T) {
body := strings.TrimSpace(`
Diese Woche stehen 3 Fristen an:
- 16.05. Klageerwiderung [#deadline-OPEN:c47bd2-1]
- 17.05. Replik [#deadline-OPEN:e92a01-3]
- 20.05. Wiedereinsetzung [#deadline-OPEN:f31b09-7]
---
[paliadin-meta]
used_tools: search_my_deadlines, lookup_court
rows_seen: 3, 1
classifier_tag: data
[/paliadin-meta]
`)
clean, meta := splitTrailer(body)
if strings.Contains(clean, "[paliadin-meta]") {
t.Fatalf("trailer not stripped from body:\n%s", clean)
}
if !strings.HasPrefix(clean, "Diese Woche") {
t.Errorf("body lost prefix: %q", clean[:50])
}
wantTools := []string{"search_my_deadlines", "lookup_court"}
if len(meta.UsedTools) != len(wantTools) {
t.Fatalf("UsedTools len = %d; want %d", len(meta.UsedTools), len(wantTools))
}
for i, want := range wantTools {
if meta.UsedTools[i] != want {
t.Errorf("UsedTools[%d] = %q; want %q", i, meta.UsedTools[i], want)
}
}
wantRows := []int{3, 1}
if len(meta.RowsSeen) != len(wantRows) {
t.Fatalf("RowsSeen len = %d; want %d", len(meta.RowsSeen), len(wantRows))
}
for i, want := range wantRows {
if meta.RowsSeen[i] != want {
t.Errorf("RowsSeen[%d] = %d; want %d", i, meta.RowsSeen[i], want)
}
}
if meta.ClassifierTag != "data" {
t.Errorf("ClassifierTag = %q; want %q", meta.ClassifierTag, "data")
}
}
func TestSplitTrailer_NoTrailer(t *testing.T) {
body := "Just a response, no trailer."
clean, meta := splitTrailer(body)
if clean != body {
t.Errorf("body changed: %q vs %q", clean, body)
}
if len(meta.UsedTools) != 0 || len(meta.RowsSeen) != 0 || meta.ClassifierTag != "" {
t.Errorf("meta should be zero: %+v", meta)
}
}
func TestSplitTrailer_EmptyToolsList(t *testing.T) {
body := strings.TrimSpace(`
Klageerwiderung ist die Erwiderung auf die Klage.
---
[paliadin-meta]
used_tools:
rows_seen:
classifier_tag: concept
[/paliadin-meta]
`)
clean, meta := splitTrailer(body)
if strings.Contains(clean, "[paliadin-meta]") {
t.Errorf("trailer not stripped")
}
if len(meta.UsedTools) != 0 {
t.Errorf("UsedTools should be empty: %v", meta.UsedTools)
}
if meta.ClassifierTag != "concept" {
t.Errorf("ClassifierTag = %q; want concept", meta.ClassifierTag)
}
}
func TestCountChips(t *testing.T) {
cases := []struct {
body string
want int
}{
{"plain text", 0},
{"see [#deadline-OPEN:abc-123]", 1},
{"two [#deadline-OPEN:abc] and [#projekt-OPEN:slug]", 2},
{"chip nav [chip:nav:/projects/123]", 1},
{"chip filter [chip:filter:status=pending]", 1},
{"mixed [#frist-OPEN:x] and [chip:nav:/y]", 2},
// Hallucinated / malformed markers don't count.
{"[#deadline-OPEN:]", 0},
{"[#deadline-OPEN]", 0},
{"[chip:invalid]", 0},
}
for _, c := range cases {
got := countChips(c.body)
if got != c.want {
t.Errorf("countChips(%q) = %d; want %d", c.body, got, c.want)
}
}
}
func TestApproxTokenCount(t *testing.T) {
cases := []struct {
s string
want int
}{
{"", 0},
{"hello", 1}, // 1 word × 1.3 → 1
{"hello world", 2}, // 2 × 1.3 = 2.6 → 2
{"one two three four five six seven", 9}, // 7 × 1.3 = 9.1 → 9
}
for _, c := range cases {
got := approxTokenCount(c.s)
if got != c.want {
t.Errorf("approxTokenCount(%q) = %d; want %d", c.s, got, c.want)
}
}
}
func TestSanitiseForTmux(t *testing.T) {
in := "first line\nsecond line\rthird"
got := sanitiseForTmux(in)
if strings.ContainsAny(got, "\n\r") {
t.Errorf("sanitiseForTmux did not strip newlines: %q", got)
}
}
func TestPaliadinOwnerEmail_IsLowercaseStable(t *testing.T) {
// Sanity check: the constant matches the email seeded in
// migration 023 verbatim. If it ever drifts, the gate would
// reject m on a fresh DB without anyone noticing.
want := "matthias.siebels@hoganlovells.com"
if PaliadinOwnerEmail != want {
t.Fatalf("PaliadinOwnerEmail = %q; want %q (matches migration 023 seed)",
PaliadinOwnerEmail, want)
}
// Lowercase invariant — the gate uses strings.EqualFold but we
// store + compare lowercase consistently anyway.
if strings.ToLower(PaliadinOwnerEmail) != PaliadinOwnerEmail {
t.Errorf("PaliadinOwnerEmail must be lowercase: %q", PaliadinOwnerEmail)
}
}
func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
long := strings.Repeat("x", 10_000)
got := sanitiseForTmux(long)
if !strings.HasSuffix(got, "[…truncated]") {
t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
}
}

View File

@@ -189,6 +189,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
}
// ---------------------------------------------------------------------------
// pathToIDStrings splits a materialised path into its UUID labels as strings,