Merge remote-tracking branch 'origin/main' into mai/kepler/inventor-profession-vs
This commit is contained in:
3
internal/db/migrations/057_email_broadcasts.down.sql
Normal file
3
internal/db/migrations/057_email_broadcasts.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 057_email_broadcasts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.email_broadcasts;
|
||||
91
internal/db/migrations/057_email_broadcasts.up.sql
Normal file
91
internal/db/migrations/057_email_broadcasts.up.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- t-paliad-147: Bulk team email — paliad.email_broadcasts.
|
||||
--
|
||||
-- Records every bulk-send sent from /team's "E-Mail an Auswahl" flow.
|
||||
-- Powers the /admin/broadcasts viewer (global_admin sees all rows;
|
||||
-- senders see their own).
|
||||
--
|
||||
-- recipient_filter snapshots the filter chips the sender had selected
|
||||
-- (project_ids, offices, roles) so a future deploy that tweaks the
|
||||
-- filter UX can still render past sends. recipient_user_ids snapshots
|
||||
-- the resolved user list — the actual addressees, immune to later
|
||||
-- team-membership changes.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE paliad.email_broadcasts.
|
||||
-- 2. Indexes.
|
||||
-- 3. RLS.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.email_broadcasts
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.email_broadcasts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Renderable subject (post-template). Stored verbatim for audit.
|
||||
subject text NOT NULL,
|
||||
|
||||
-- Body source as the sender typed it (Markdown). NOT the per-recipient
|
||||
-- rendered output — those are reconstructable by re-rendering with the
|
||||
-- snapshotted recipient row, but the source is what we audit.
|
||||
body text NOT NULL,
|
||||
|
||||
-- The sender. FK to paliad.users (not auth.users) so deleting an auth
|
||||
-- row leaves the audit trail intact via paliad.users.
|
||||
sender_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
|
||||
-- Optional template the sender started from. NULL when freeform.
|
||||
template_key text,
|
||||
|
||||
-- Snapshot of filter chips selected at send time. Keys: project_ids
|
||||
-- (uuid[]), offices (text[]), roles (text[]). jsonb for forward-compat.
|
||||
recipient_filter jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Resolved addressee list — the user_ids that received (or attempted)
|
||||
-- the mail. Immune to subsequent team-membership changes.
|
||||
recipient_user_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
|
||||
-- Per-send result counts (sent, failed, total). jsonb so we can grow
|
||||
-- the report shape without a migration.
|
||||
send_report jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX email_broadcasts_sent_at_idx
|
||||
ON paliad.email_broadcasts (sent_at DESC);
|
||||
|
||||
CREATE INDEX email_broadcasts_sender_idx
|
||||
ON paliad.email_broadcasts (sender_id, sent_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.email_broadcasts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Senders can read their own rows; global_admin can read everything.
|
||||
-- The Go service layer (BroadcastService) is the load-bearing gate; RLS
|
||||
-- here is defence-in-depth for any future auth-context query path.
|
||||
CREATE POLICY email_broadcasts_select
|
||||
ON paliad.email_broadcasts FOR SELECT
|
||||
USING (
|
||||
sender_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Inserts only by the sender themselves (defence-in-depth — the service
|
||||
-- enforces project_lead-OR-global_admin authorship; RLS only enforces the
|
||||
-- self-attribution bit).
|
||||
CREATE POLICY email_broadcasts_insert
|
||||
ON paliad.email_broadcasts FOR INSERT
|
||||
WITH CHECK (sender_id = auth.uid());
|
||||
3
internal/db/migrations/058_paliadin_poc.down.sql
Normal file
3
internal/db/migrations/058_paliadin_poc.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-146: Paliadin PoC — drop paliad.paliadin_turns.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.paliadin_turns;
|
||||
142
internal/db/migrations/058_paliadin_poc.up.sql
Normal file
142
internal/db/migrations/058_paliadin_poc.up.sql
Normal 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.';
|
||||
197
internal/handlers/broadcasts.go
Normal file
197
internal/handlers/broadcasts.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// broadcasts.go — bulk team-email send (t-paliad-147 / issue #7).
|
||||
//
|
||||
// One write endpoint (/api/team/broadcast) and a pair of read endpoints
|
||||
// for the /admin/broadcasts viewer.
|
||||
//
|
||||
// The /api/team/broadcast handler enforces the project-lead-OR-global_admin
|
||||
// authorisation in BroadcastService.Send, so non-leads receive 403.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// broadcastRequest is the JSON body for POST /api/team/broadcast.
|
||||
//
|
||||
// Recipients carry the addresseelist as resolved on the client side: the
|
||||
// frontend filters the displayed team table, then submits the user_ids the
|
||||
// user wanted to mail. The server validates each address and rejects if
|
||||
// any is malformed.
|
||||
type broadcastRequest struct {
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
TemplateKey string `json:"template_key,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
RecipientFilter map[string]any `json:"recipient_filter,omitempty"`
|
||||
Recipients []broadcastRequestRecipient `json:"recipients"`
|
||||
}
|
||||
|
||||
type broadcastRequestRecipient struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
RoleOnProject string `json:"role_on_project"`
|
||||
}
|
||||
|
||||
// POST /api/team/broadcast — dispatch a personalised email to a filtered
|
||||
// team subset. Returns the broadcast ID and per-recipient send report.
|
||||
func handleTeamBroadcast(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.broadcast == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "broadcasts unavailable — broadcast service not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req broadcastRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
in := services.BroadcastInput{
|
||||
ProjectID: req.ProjectID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
TemplateKey: req.TemplateKey,
|
||||
Lang: req.Lang,
|
||||
RecipientFilter: req.RecipientFilter,
|
||||
Recipients: make([]services.BroadcastRecipient, 0, len(req.Recipients)),
|
||||
}
|
||||
for _, rc := range req.Recipients {
|
||||
in.Recipients = append(in.Recipients, services.BroadcastRecipient{
|
||||
UserID: rc.UserID,
|
||||
Email: rc.Email,
|
||||
DisplayName: rc.DisplayName,
|
||||
FirstName: rc.FirstName,
|
||||
RoleOnProject: rc.RoleOnProject,
|
||||
})
|
||||
}
|
||||
|
||||
report, err := dbSvc.broadcast.Send(r.Context(), uid, in)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrBroadcastForbidden):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "only project leads or global admins can send broadcasts",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastNoRecipients):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "no recipients selected",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastTooManyRecipients):
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastEmptySubject):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "subject is required",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastEmptyBody):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "body is required",
|
||||
})
|
||||
case errors.Is(err, services.ErrBroadcastInvalidEmail):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to send broadcast",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, report)
|
||||
}
|
||||
|
||||
// GET /api/admin/broadcasts — list broadcasts visible to the caller.
|
||||
// global_admin sees all rows; senders see their own.
|
||||
//
|
||||
// Lives behind the gateOnboarded gate (not adminGate) so a project lead
|
||||
// who's never been promoted to global_admin can still see their own
|
||||
// sends.
|
||||
func handleListBroadcasts(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.broadcast == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "broadcasts unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
rows, err := dbSvc.broadcast.List(r.Context(), uid, limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBroadcastForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/admin/broadcasts/{id} — full detail for one broadcast.
|
||||
func handleGetBroadcast(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.broadcast == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "broadcasts unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
detail, err := dbSvc.broadcast.Get(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBroadcastForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// GET /admin/broadcasts — server-rendered shell.
|
||||
func handleAdminBroadcastsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-broadcasts.html")
|
||||
}
|
||||
@@ -65,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.
|
||||
|
||||
351
internal/handlers/paliadin.go
Normal file
351
internal/handlers/paliadin.go
Normal 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).
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type dbServices struct {
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
@@ -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) {
|
||||
|
||||
587
internal/services/broadcast_service.go
Normal file
587
internal/services/broadcast_service.go
Normal file
@@ -0,0 +1,587 @@
|
||||
// Package services — BroadcastService — bulk team-email send.
|
||||
//
|
||||
// Backs the /team page "E-Mail an Auswahl" flow (t-paliad-147 / issue #7).
|
||||
// Each call:
|
||||
//
|
||||
// 1. Validates the sender's authority (project lead OR global_admin)
|
||||
// and the recipient cap.
|
||||
// 2. Renders the per-recipient body (Markdown → HTML, with
|
||||
// {{name}} / {{first_name}} / {{role_on_project}} placeholder
|
||||
// substitution) inside the standard email base wrapper.
|
||||
// 3. Dispatches via MailService.Send with Reply-To set to the
|
||||
// sender's address — From: stays on the SMTP infra address so
|
||||
// DKIM/SPF still hold. Replies route back to the human.
|
||||
// 4. Persists a paliad.email_broadcasts row capturing subject,
|
||||
// body, sender, filter snapshot, and per-recipient send report.
|
||||
//
|
||||
// Per-recipient privacy: each recipient gets their own envelope. We
|
||||
// never put more than one address on the To: header. Recipients can't
|
||||
// see each other.
|
||||
//
|
||||
// Concurrency: a fixed 5-deep goroutine pool dispatches sends with a
|
||||
// per-send timeout. SMTP failures are logged into the report and the
|
||||
// batch continues — one bad address never blocks the rest.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// BroadcastRecipientCap is the soft maximum number of recipients per
|
||||
// broadcast. m-locked at 100 (2026-05-07) — admin-tweakable later if
|
||||
// HLC's regular use case grows.
|
||||
const BroadcastRecipientCap = 100
|
||||
|
||||
// BroadcastSendConcurrency caps the number of in-flight SMTP
|
||||
// connections during a single broadcast. Five is generous enough to
|
||||
// finish a 100-recipient batch in a few seconds while leaving headroom
|
||||
// for the reminder job's own SMTP usage.
|
||||
const BroadcastSendConcurrency = 5
|
||||
|
||||
// BroadcastSendTimeout bounds a single per-recipient SMTP delivery.
|
||||
// Hostinger's submission endpoint typically returns within a second;
|
||||
// 15s gives plenty of slack for transient slowness without holding the
|
||||
// HTTP request open indefinitely.
|
||||
const BroadcastSendTimeout = 15 * time.Second
|
||||
|
||||
// Sentinel errors. Handlers map these to HTTP status codes.
|
||||
var (
|
||||
ErrBroadcastForbidden = errors.New("broadcast: caller is neither project lead nor global_admin")
|
||||
ErrBroadcastNoRecipients = errors.New("broadcast: empty recipient list")
|
||||
ErrBroadcastTooManyRecipients = errors.New("broadcast: recipient cap exceeded")
|
||||
ErrBroadcastEmptySubject = errors.New("broadcast: empty subject")
|
||||
ErrBroadcastEmptyBody = errors.New("broadcast: empty body")
|
||||
ErrBroadcastInvalidEmail = errors.New("broadcast: invalid recipient email")
|
||||
)
|
||||
|
||||
// BroadcastService wires the bulk-send flow.
|
||||
type BroadcastService struct {
|
||||
db *sqlx.DB
|
||||
mail *MailService
|
||||
users *UserService
|
||||
team *TeamService
|
||||
templates *EmailTemplateService
|
||||
|
||||
// clock isolates time.Now for tests.
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewBroadcastService wires the service. mail/users/team/templates
|
||||
// must all be non-nil — the service is only constructed in the DB-backed
|
||||
// path.
|
||||
func NewBroadcastService(db *sqlx.DB, mail *MailService, users *UserService, team *TeamService, templates *EmailTemplateService) *BroadcastService {
|
||||
return &BroadcastService{
|
||||
db: db,
|
||||
mail: mail,
|
||||
users: users,
|
||||
team: team,
|
||||
templates: templates,
|
||||
clock: func() time.Time { return time.Now() },
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastRecipient is one row in the resolved addressee list. Name
|
||||
// values are the per-recipient placeholder substitutions surfaced in
|
||||
// the body.
|
||||
type BroadcastRecipient struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
DisplayName string
|
||||
FirstName string
|
||||
RoleOnProject string
|
||||
}
|
||||
|
||||
// BroadcastInput is what a handler hands to Send.
|
||||
type BroadcastInput struct {
|
||||
// ProjectID identifies the project the broadcast is scoped to. The
|
||||
// caller must be a 'lead' on this project (or a global_admin) for
|
||||
// the send to proceed. nil/zero means "no specific project" —
|
||||
// only global_admin may send in that case.
|
||||
ProjectID *uuid.UUID
|
||||
|
||||
Subject string
|
||||
// Body is the Markdown source the sender typed. Per-recipient
|
||||
// placeholders ({{name}}, {{first_name}}, {{role_on_project}})
|
||||
// are substituted before Markdown rendering.
|
||||
Body string
|
||||
|
||||
// TemplateKey is optional — when set, the broadcast is recorded as
|
||||
// having started from a template, but Subject/Body are still the
|
||||
// authoritative source (we don't re-fetch from the template at
|
||||
// send time).
|
||||
TemplateKey string
|
||||
|
||||
// RecipientFilter is the snapshot of filter chips the sender had
|
||||
// selected. Persisted into email_broadcasts.recipient_filter for
|
||||
// future audit.
|
||||
RecipientFilter map[string]any
|
||||
|
||||
Recipients []BroadcastRecipient
|
||||
|
||||
// Lang controls the wrapper template language. Defaults to "de".
|
||||
Lang string
|
||||
}
|
||||
|
||||
// BroadcastReport summarises a send.
|
||||
type BroadcastReport struct {
|
||||
BroadcastID uuid.UUID `json:"broadcast_id"`
|
||||
Total int `json:"total"`
|
||||
Sent int `json:"sent"`
|
||||
Failed int `json:"failed"`
|
||||
Errors map[string]string `json:"errors,omitempty"` // user_id → error
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
}
|
||||
|
||||
// Send dispatches a broadcast. Returns the persisted ID and a per-send
|
||||
// report. The full pipeline runs even when MailService is disabled —
|
||||
// the audit row still lands so deploys without SMTP can be exercised.
|
||||
func (s *BroadcastService) Send(ctx context.Context, callerID uuid.UUID, in BroadcastInput) (*BroadcastReport, error) {
|
||||
// --- Validation (cheap checks first) ----------------------------
|
||||
subject := strings.TrimSpace(in.Subject)
|
||||
if subject == "" {
|
||||
return nil, ErrBroadcastEmptySubject
|
||||
}
|
||||
body := strings.TrimSpace(in.Body)
|
||||
if body == "" {
|
||||
return nil, ErrBroadcastEmptyBody
|
||||
}
|
||||
if len(in.Recipients) == 0 {
|
||||
return nil, ErrBroadcastNoRecipients
|
||||
}
|
||||
if len(in.Recipients) > BroadcastRecipientCap {
|
||||
return nil, fmt.Errorf("%w: %d > %d", ErrBroadcastTooManyRecipients, len(in.Recipients), BroadcastRecipientCap)
|
||||
}
|
||||
for _, r := range in.Recipients {
|
||||
if _, err := mail.ParseAddress(r.Email); err != nil {
|
||||
return nil, fmt.Errorf("%w: %q", ErrBroadcastInvalidEmail, r.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Authorisation ---------------------------------------------
|
||||
sender, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sender: %w", err)
|
||||
}
|
||||
if sender == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
if err := s.assertCanBroadcast(ctx, sender, in.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Persist audit row ahead of send so a partial-batch crash
|
||||
// still leaves a record of intent. send_report is filled in
|
||||
// post-dispatch via UPDATE.
|
||||
lang := in.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
broadcastID := uuid.New()
|
||||
recipientIDs := make([]uuid.UUID, 0, len(in.Recipients))
|
||||
for _, r := range in.Recipients {
|
||||
recipientIDs = append(recipientIDs, r.UserID)
|
||||
}
|
||||
filterJSON, err := json.Marshal(filterMapOrEmpty(in.RecipientFilter))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal filter: %w", err)
|
||||
}
|
||||
|
||||
templateKey := strings.TrimSpace(in.TemplateKey)
|
||||
var templateKeyArg any
|
||||
if templateKey != "" {
|
||||
templateKeyArg = templateKey
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.email_broadcasts
|
||||
(id, subject, body, sender_id, template_key, recipient_filter, recipient_user_ids, send_report, sent_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, '{}'::jsonb, now())`,
|
||||
broadcastID, subject, body, callerID, templateKeyArg, string(filterJSON), pq.Array(recipientIDs),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert broadcast: %w", err)
|
||||
}
|
||||
|
||||
// --- Dispatch -------------------------------------------------
|
||||
report, sendErr := s.dispatch(ctx, *sender, broadcastID, subject, body, lang, in.Recipients)
|
||||
report.BroadcastID = broadcastID
|
||||
|
||||
// Persist the report regardless of dispatch outcome; surface the
|
||||
// dispatch error to the caller so the UI can show a partial-success
|
||||
// toast.
|
||||
reportJSON, marshalErr := json.Marshal(report)
|
||||
if marshalErr != nil {
|
||||
// Truly unexpected — fall back to an empty report shape rather
|
||||
// than wedging the audit row.
|
||||
slog.Error("broadcast: marshal report failed", "broadcast_id", broadcastID, "error", marshalErr)
|
||||
reportJSON = []byte(`{}`)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.email_broadcasts SET send_report = $1::jsonb WHERE id = $2`,
|
||||
string(reportJSON), broadcastID,
|
||||
); err != nil {
|
||||
slog.Error("broadcast: persist report failed", "broadcast_id", broadcastID, "error", err)
|
||||
}
|
||||
|
||||
if sendErr != nil {
|
||||
return report, sendErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// assertCanBroadcast enforces project_lead-OR-global_admin. global_admin
|
||||
// always wins; otherwise the sender must have role='lead' on
|
||||
// in.ProjectID.
|
||||
func (s *BroadcastService) assertCanBroadcast(ctx context.Context, sender *models.User, projectID *uuid.UUID) error {
|
||||
if sender.GlobalRole == "global_admin" {
|
||||
return nil
|
||||
}
|
||||
if projectID == nil {
|
||||
return ErrBroadcastForbidden
|
||||
}
|
||||
var count int
|
||||
if err := s.db.GetContext(ctx, &count,
|
||||
`SELECT COUNT(*) FROM paliad.project_teams
|
||||
WHERE project_id = $1 AND user_id = $2 AND role = 'lead'`,
|
||||
*projectID, sender.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("check lead role: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrBroadcastForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch fans out the per-recipient sends through a bounded pool and
|
||||
// collects the report.
|
||||
func (s *BroadcastService) dispatch(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, recipients []BroadcastRecipient) (*BroadcastReport, error) {
|
||||
type result struct {
|
||||
userID uuid.UUID
|
||||
err error
|
||||
}
|
||||
results := make(chan result, len(recipients))
|
||||
|
||||
sem := make(chan struct{}, BroadcastSendConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, r := range recipients {
|
||||
wg.Add(1)
|
||||
go func(rec BroadcastRecipient) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
sendCtx, cancel := context.WithTimeout(ctx, BroadcastSendTimeout)
|
||||
defer cancel()
|
||||
err := s.sendOne(sendCtx, sender, broadcastID, subject, body, lang, rec)
|
||||
results <- result{userID: rec.UserID, err: err}
|
||||
}(r)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
report := &BroadcastReport{
|
||||
Total: len(recipients),
|
||||
Errors: map[string]string{},
|
||||
SentAt: s.clock(),
|
||||
}
|
||||
for res := range results {
|
||||
if res.err != nil {
|
||||
report.Failed++
|
||||
report.Errors[res.userID.String()] = res.err.Error()
|
||||
slog.Warn("broadcast: send failed",
|
||||
"broadcast_id", broadcastID, "user_id", res.userID, "error", res.err)
|
||||
} else {
|
||||
report.Sent++
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// sendOne renders one personalised email and dispatches it. The
|
||||
// MailService no-ops cleanly when disabled — that path still treats
|
||||
// the recipient as "sent" for the purposes of the report so dev
|
||||
// deploys aren't littered with phantom failures.
|
||||
func (s *BroadcastService) sendOne(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, rec BroadcastRecipient) error {
|
||||
// Subject can carry placeholders too ("Hallo {{first_name}}, …").
|
||||
rendered := substitutePlaceholders(subject, rec)
|
||||
personalisedBody := substitutePlaceholders(body, rec)
|
||||
htmlBody, err := s.renderBroadcastBody(ctx, lang, personalisedBody, sender)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render body: %w", err)
|
||||
}
|
||||
textBody := htmlToText(htmlBody)
|
||||
|
||||
// Custom envelope — we want Reply-To: sender so replies route to the
|
||||
// human who composed the broadcast.
|
||||
if !s.mail.Enabled() {
|
||||
slog.Debug("broadcast: SendOne skipped (mail disabled)",
|
||||
"broadcast_id", broadcastID, "to", rec.Email)
|
||||
return nil
|
||||
}
|
||||
msg := buildMIMEWithReplyTo(s.mail.cfg.From, s.mail.cfg.FromName, sender.Email,
|
||||
rec.Email, rendered, htmlBody, textBody)
|
||||
deliverDone := make(chan error, 1)
|
||||
go func() {
|
||||
deliverDone <- s.mail.deliver(rec.Email, msg)
|
||||
}()
|
||||
select {
|
||||
case err := <-deliverDone:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// renderBroadcastBody wraps the personalised Markdown body in the
|
||||
// standard base.html (DB override or embedded fallback) so broadcast
|
||||
// emails look like the rest of Paliad's mail.
|
||||
func (s *BroadcastService) renderBroadcastBody(ctx context.Context, lang, markdownBody string, sender models.User) (string, error) {
|
||||
htmlContent := renderMarkdownSafe(markdownBody)
|
||||
signature := senderSignature(lang, sender)
|
||||
|
||||
// Build the {{define "content"}} block expected by base.html. The
|
||||
// inner HTML is treated as trusted output (we generated it from
|
||||
// known-safe Markdown rules). Senders can't sneak script tags
|
||||
// because renderMarkdownSafe escapes everything before re-introducing
|
||||
// the whitelisted markup.
|
||||
contentBlock := fmt.Sprintf(`{{define "content"}}%s%s{{end}}`, htmlContent, signature)
|
||||
|
||||
// Look up base.html (key='base'). Same fallback discipline as
|
||||
// MailService.RenderTemplate — if the active row is malformed we
|
||||
// retry with the embedded default.
|
||||
var (
|
||||
baseBody string
|
||||
err error
|
||||
)
|
||||
if s.templates != nil {
|
||||
row, lookupErr := s.templates.GetActive(ctx, EmailTemplateKeyBase, lang)
|
||||
if lookupErr != nil {
|
||||
return "", fmt.Errorf("lookup base template: %w", lookupErr)
|
||||
}
|
||||
baseBody = row.Body
|
||||
} else {
|
||||
baseBody, err = readEmbeddedBody(EmailTemplateKeyBase, lang)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read embedded base: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"Lang": lang,
|
||||
"Firm": branding.Name,
|
||||
"Subject": "", // base.html title field; we don't need it here.
|
||||
}
|
||||
|
||||
html, err := renderBaseAndContent(baseBody, contentBlock, payload)
|
||||
if err == nil {
|
||||
return html, nil
|
||||
}
|
||||
// Active row malformed — fall back to embedded.
|
||||
slog.Error("broadcast: base render failed, falling back to embedded",
|
||||
"lang", lang, "error", err)
|
||||
fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
|
||||
if fbErr != nil {
|
||||
return "", fmt.Errorf("fallback base: %w", fbErr)
|
||||
}
|
||||
return renderBaseAndContent(fbBase, contentBlock, payload)
|
||||
}
|
||||
|
||||
// substitutePlaceholders replaces {{name}}, {{first_name}}, and
|
||||
// {{role_on_project}} with the per-recipient values. Whitespace
|
||||
// inside the braces is tolerated. Unknown {{...}} tokens pass through
|
||||
// untouched so a sender's accidental "literal {{example}}" stays
|
||||
// readable in the rendered mail.
|
||||
func substitutePlaceholders(src string, rec BroadcastRecipient) string {
|
||||
repl := strings.NewReplacer(
|
||||
"{{name}}", rec.DisplayName,
|
||||
"{{ name }}", rec.DisplayName,
|
||||
"{{first_name}}", rec.FirstName,
|
||||
"{{ first_name }}", rec.FirstName,
|
||||
"{{role_on_project}}", rec.RoleOnProject,
|
||||
"{{ role_on_project }}", rec.RoleOnProject,
|
||||
)
|
||||
return repl.Replace(src)
|
||||
}
|
||||
|
||||
// senderSignature appends a "Geschickt von <DisplayName> <email>"
|
||||
// footer below the body so the recipient sees who wrote the mail
|
||||
// even though From: is the SMTP infrastructure address.
|
||||
func senderSignature(lang string, sender models.User) string {
|
||||
prefix := "Gesendet von"
|
||||
if lang == "en" {
|
||||
prefix = "Sent by"
|
||||
}
|
||||
if sender.DisplayName == "" {
|
||||
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s <a href="mailto:%s">%s</a></p>`,
|
||||
prefix, escapeHTML(sender.Email), escapeHTML(sender.Email))
|
||||
}
|
||||
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s %s <<a href="mailto:%s">%s</a>></p>`,
|
||||
prefix, escapeHTML(sender.DisplayName), escapeHTML(sender.Email), escapeHTML(sender.Email))
|
||||
}
|
||||
|
||||
// filterMapOrEmpty normalises a nil filter map to an empty one for
|
||||
// jsonb persistence.
|
||||
func filterMapOrEmpty(in map[string]any) map[string]any {
|
||||
if in == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// --- broadcast list / get queries ----------------------------------
|
||||
|
||||
// BroadcastListEntry is one row on the /admin/broadcasts list.
|
||||
type BroadcastListEntry struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Subject string `db:"subject" json:"subject"`
|
||||
SenderID uuid.UUID `db:"sender_id" json:"sender_id"`
|
||||
SenderName string `db:"sender_name" json:"sender_name"`
|
||||
SenderEmail string `db:"sender_email" json:"sender_email"`
|
||||
RecipientCount int `db:"recipient_count" json:"recipient_count"`
|
||||
SentAt time.Time `db:"sent_at" json:"sent_at"`
|
||||
TemplateKey *string `db:"template_key" json:"template_key,omitempty"`
|
||||
}
|
||||
|
||||
// BroadcastDetail is the per-row detail view.
|
||||
type BroadcastDetail struct {
|
||||
BroadcastListEntry
|
||||
Body string `db:"body" json:"body"`
|
||||
RecipientFilter json.RawMessage `db:"recipient_filter" json:"recipient_filter"`
|
||||
SendReport json.RawMessage `db:"send_report" json:"send_report"`
|
||||
Recipients []BroadcastDetailRecipient `json:"recipients"`
|
||||
}
|
||||
|
||||
// BroadcastDetailRecipient is one resolved addressee on the detail page.
|
||||
// Names are joined from paliad.users at read time so the most recent
|
||||
// display_name shows up; the audit row only retains the user_id.
|
||||
type BroadcastDetailRecipient struct {
|
||||
UserID uuid.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
}
|
||||
|
||||
// List returns broadcasts visible to the caller. global_admin sees
|
||||
// every row; everyone else sees only their own sends.
|
||||
func (s *BroadcastService) List(ctx context.Context, callerID uuid.UUID, limit int) ([]BroadcastListEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
caller, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller: %w", err)
|
||||
}
|
||||
if caller == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
|
||||
var (
|
||||
rows []BroadcastListEntry
|
||||
q string
|
||||
args []any
|
||||
)
|
||||
if caller.GlobalRole == "global_admin" {
|
||||
q = listBroadcastsSQL + ` ORDER BY b.sent_at DESC LIMIT $1`
|
||||
args = []any{limit}
|
||||
} else {
|
||||
q = listBroadcastsSQL + ` WHERE b.sender_id = $1 ORDER BY b.sent_at DESC LIMIT $2`
|
||||
args = []any{callerID, limit}
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list broadcasts: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one broadcast plus its resolved recipient list. Same
|
||||
// visibility rules as List.
|
||||
func (s *BroadcastService) Get(ctx context.Context, callerID, id uuid.UUID) (*BroadcastDetail, error) {
|
||||
caller, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller: %w", err)
|
||||
}
|
||||
if caller == nil {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
var detail BroadcastDetail
|
||||
q := `
|
||||
SELECT b.id, b.subject, b.sender_id, b.template_key,
|
||||
array_length(b.recipient_user_ids, 1) AS recipient_count,
|
||||
b.sent_at, b.body, b.recipient_filter, b.send_report,
|
||||
u.display_name AS sender_name, u.email AS sender_email
|
||||
FROM paliad.email_broadcasts b
|
||||
LEFT JOIN paliad.users u ON u.id = b.sender_id
|
||||
WHERE b.id = $1`
|
||||
if err := s.db.GetContext(ctx, &detail, q, id); err != nil {
|
||||
return nil, fmt.Errorf("get broadcast: %w", err)
|
||||
}
|
||||
if caller.GlobalRole != "global_admin" && detail.SenderID != callerID {
|
||||
return nil, ErrBroadcastForbidden
|
||||
}
|
||||
|
||||
// Resolve recipient names. The audit row stores user_ids only; we
|
||||
// re-join paliad.users at read time so renames flow through. The
|
||||
// uuid[] column comes back as pq.Array; copy it out for sqlx.
|
||||
var idArr pq.StringArray
|
||||
if err := s.db.GetContext(ctx, &idArr,
|
||||
`SELECT recipient_user_ids::text[] FROM paliad.email_broadcasts WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("load recipient ids: %w", err)
|
||||
}
|
||||
recipientIDs := make([]uuid.UUID, 0, len(idArr))
|
||||
for _, s := range idArr {
|
||||
if uid, err := uuid.Parse(s); err == nil {
|
||||
recipientIDs = append(recipientIDs, uid)
|
||||
}
|
||||
}
|
||||
if len(recipientIDs) > 0 {
|
||||
var rec []BroadcastDetailRecipient
|
||||
if err := s.db.SelectContext(ctx, &rec,
|
||||
`SELECT id, email, display_name
|
||||
FROM paliad.users
|
||||
WHERE id = ANY($1)`, pq.Array(recipientIDs),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load recipients: %w", err)
|
||||
}
|
||||
// Preserve the audit-row order — clients want the original
|
||||
// dispatch list, not whatever paliad.users ordered them by.
|
||||
byID := make(map[uuid.UUID]BroadcastDetailRecipient, len(rec))
|
||||
for _, r := range rec {
|
||||
byID[r.UserID] = r
|
||||
}
|
||||
ordered := make([]BroadcastDetailRecipient, 0, len(recipientIDs))
|
||||
for _, uid := range recipientIDs {
|
||||
if r, ok := byID[uid]; ok {
|
||||
ordered = append(ordered, r)
|
||||
continue
|
||||
}
|
||||
// User row was deleted post-broadcast. Show the bare ID so
|
||||
// the audit page still accounts for the slot.
|
||||
ordered = append(ordered, BroadcastDetailRecipient{UserID: uid})
|
||||
}
|
||||
detail.Recipients = ordered
|
||||
}
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
const listBroadcastsSQL = `
|
||||
SELECT b.id, b.subject, b.sender_id, b.template_key,
|
||||
COALESCE(array_length(b.recipient_user_ids, 1), 0) AS recipient_count,
|
||||
b.sent_at,
|
||||
u.display_name AS sender_name, u.email AS sender_email
|
||||
FROM paliad.email_broadcasts b
|
||||
LEFT JOIN paliad.users u ON u.id = b.sender_id
|
||||
`
|
||||
|
||||
191
internal/services/broadcast_service_live_test.go
Normal file
191
internal/services/broadcast_service_live_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestBroadcastService_SendAndAudit_Live exercises the full BroadcastService
|
||||
// pipeline against a real Postgres: the row lands in paliad.email_broadcasts,
|
||||
// the send_report jsonb captures per-recipient outcomes, and List/Get
|
||||
// honours the visibility rules (sender sees own; global_admin sees all).
|
||||
//
|
||||
// SMTP delivery is not exercised — the MailService is left disabled
|
||||
// (Enabled() == false) so sendOne short-circuits cleanly. That's the same
|
||||
// contract the dev/preview deploys run under.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestBroadcastService_SendAndAudit_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
leadID := uuid.New()
|
||||
memberID := uuid.New()
|
||||
otherSenderID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Bcast Lead', 'munich', 'standard', 'de'),
|
||||
($3, $4, 'Bcast Mem', 'munich', 'standard', 'de'),
|
||||
($5, $6, 'Bcast Admin', 'munich', 'global_admin', 'de')`,
|
||||
leadID, "bcast-lead@hlc.com",
|
||||
memberID, "bcast-member@hlc.com",
|
||||
otherSenderID, "bcast-admin@hlc.com",
|
||||
); err != nil {
|
||||
t.Fatalf("seed users: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.email_broadcasts WHERE sender_id = ANY($1)`,
|
||||
[]string{leadID.String(), otherSenderID.String()})
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = ANY($1)`,
|
||||
[]string{leadID.String(), memberID.String(), otherSenderID.String()})
|
||||
})
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, status, created_by)
|
||||
VALUES ($1, 'project', $1::text, 'Bcast Project', 'active', $2)`,
|
||||
projectID, leadID,
|
||||
); err != nil {
|
||||
t.Fatalf("seed project: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', false, $2),
|
||||
($1, $3, 'associate', false, $2)`,
|
||||
projectID, leadID, memberID,
|
||||
); err != nil {
|
||||
t.Fatalf("seed team: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projectSvc := NewProjectService(pool, users)
|
||||
teamSvc := NewTeamService(pool, projectSvc)
|
||||
mailSvc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("mail svc: %v", err)
|
||||
}
|
||||
tplSvc := NewEmailTemplateService(pool)
|
||||
mailSvc.SetTemplateService(tplSvc)
|
||||
bcast := NewBroadcastService(pool, mailSvc, users, teamSvc, tplSvc)
|
||||
|
||||
// --- 1. lead can send a broadcast on their project --------------
|
||||
pid := projectID
|
||||
report, err := bcast.Send(ctx, leadID, BroadcastInput{
|
||||
ProjectID: &pid,
|
||||
Subject: "Hallo Team",
|
||||
Body: "Hi {{first_name}}, kurze Nachricht.",
|
||||
Recipients: []BroadcastRecipient{{
|
||||
UserID: memberID,
|
||||
Email: "bcast-member@hlc.com",
|
||||
DisplayName: "Bcast Mem",
|
||||
FirstName: "Bcast",
|
||||
RoleOnProject: "associate",
|
||||
}},
|
||||
RecipientFilter: map[string]any{"project_ids": []string{pid.String()}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send (lead): %v", err)
|
||||
}
|
||||
if report.BroadcastID == uuid.Nil {
|
||||
t.Fatal("BroadcastID empty")
|
||||
}
|
||||
if report.Total != 1 {
|
||||
t.Errorf("Total=%d, want 1", report.Total)
|
||||
}
|
||||
if report.Sent != 1 || report.Failed != 0 {
|
||||
t.Errorf("Sent=%d Failed=%d, want Sent=1 Failed=0", report.Sent, report.Failed)
|
||||
}
|
||||
|
||||
// --- 2. non-lead sender (member) → forbidden --------------------
|
||||
_, err = bcast.Send(ctx, memberID, BroadcastInput{
|
||||
ProjectID: &pid,
|
||||
Subject: "Should fail",
|
||||
Body: "x",
|
||||
Recipients: []BroadcastRecipient{{
|
||||
UserID: leadID, Email: "bcast-lead@hlc.com", DisplayName: "Bcast Lead",
|
||||
}},
|
||||
})
|
||||
if err == nil || !errorIs(err, ErrBroadcastForbidden) {
|
||||
t.Errorf("non-lead Send: got %v, want ErrBroadcastForbidden", err)
|
||||
}
|
||||
|
||||
// --- 3. global_admin sees all rows in List ----------------------
|
||||
rowsAdmin, err := bcast.List(ctx, otherSenderID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("List(admin): %v", err)
|
||||
}
|
||||
foundOurRow := false
|
||||
for _, r := range rowsAdmin {
|
||||
if r.ID == report.BroadcastID {
|
||||
foundOurRow = true
|
||||
if r.RecipientCount != 1 {
|
||||
t.Errorf("RecipientCount=%d, want 1", r.RecipientCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOurRow {
|
||||
t.Error("admin's List did not include our broadcast")
|
||||
}
|
||||
|
||||
// --- 4. lead sees own rows --------------------------------------
|
||||
rowsLead, err := bcast.List(ctx, leadID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("List(lead): %v", err)
|
||||
}
|
||||
if len(rowsLead) == 0 || rowsLead[0].ID != report.BroadcastID {
|
||||
t.Errorf("lead List didn't return own row; got %+v", rowsLead)
|
||||
}
|
||||
|
||||
// --- 5. non-sender, non-admin gets nothing back -----------------
|
||||
rowsMember, err := bcast.List(ctx, memberID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("List(member): %v", err)
|
||||
}
|
||||
for _, r := range rowsMember {
|
||||
if r.ID == report.BroadcastID {
|
||||
t.Errorf("member should not see lead's broadcast %s", r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6. Get returns full detail w/ recipients -------------------
|
||||
detail, err := bcast.Get(ctx, leadID, report.BroadcastID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if detail.Subject != "Hallo Team" {
|
||||
t.Errorf("Subject=%q", detail.Subject)
|
||||
}
|
||||
if len(detail.Recipients) != 1 {
|
||||
t.Errorf("Recipients=%d, want 1", len(detail.Recipients))
|
||||
}
|
||||
if len(detail.Recipients) >= 1 && detail.Recipients[0].UserID != memberID {
|
||||
t.Errorf("Recipients[0].UserID=%s, want %s", detail.Recipients[0].UserID, memberID)
|
||||
}
|
||||
|
||||
// --- 7. member calling Get on lead's row → forbidden -----------
|
||||
if _, err := bcast.Get(ctx, memberID, report.BroadcastID); err == nil ||
|
||||
!errorIs(err, ErrBroadcastForbidden) {
|
||||
t.Errorf("member Get: got %v, want ErrBroadcastForbidden", err)
|
||||
}
|
||||
}
|
||||
233
internal/services/broadcast_service_test.go
Normal file
233
internal/services/broadcast_service_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestSubstitutePlaceholders(t *testing.T) {
|
||||
rec := BroadcastRecipient{
|
||||
UserID: uuid.New(),
|
||||
Email: "anna@hlc.com",
|
||||
DisplayName: "Anna Beispiel",
|
||||
FirstName: "Anna",
|
||||
RoleOnProject: "lead",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"name", "Hallo {{name}}", "Hallo Anna Beispiel"},
|
||||
{"first_name", "Hi {{first_name}}!", "Hi Anna!"},
|
||||
{"role_on_project", "Du bist {{role_on_project}}.", "Du bist lead."},
|
||||
{"whitespace tolerated", "{{ first_name }}", "Anna"},
|
||||
{"unknown token passes through", "Literal {{example}} stays", "Literal {{example}} stays"},
|
||||
{"all three together",
|
||||
"{{name}} ({{first_name}}, {{role_on_project}})",
|
||||
"Anna Beispiel (Anna, lead)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := substitutePlaceholders(tc.in, rec)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// renderMarkdownSafe must escape raw HTML and only re-emit a small whitelist
|
||||
// of tags. Any leakage of a <script> tag would be an XSS vector since the
|
||||
// rendered output goes straight into an HTML email body.
|
||||
func TestRenderMarkdownSafe(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantContains []string
|
||||
wantMissing []string
|
||||
}{
|
||||
{
|
||||
name: "bold",
|
||||
in: "**hallo**",
|
||||
wantContains: []string{"<strong>hallo</strong>"},
|
||||
},
|
||||
{
|
||||
name: "italic underscore",
|
||||
in: "_hallo_",
|
||||
wantContains: []string{"<em>hallo</em>"},
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
in: "[paliad](https://paliad.de)",
|
||||
wantContains: []string{`<a href="https://paliad.de">paliad</a>`},
|
||||
},
|
||||
{
|
||||
name: "bullet list",
|
||||
in: "- erstens\n- zweitens",
|
||||
wantContains: []string{"<ul>", "<li>erstens</li>", "<li>zweitens</li>", "</ul>"},
|
||||
},
|
||||
{
|
||||
name: "paragraph break",
|
||||
in: "Erste Zeile\n\nZweite Zeile",
|
||||
wantContains: []string{"<p>Erste Zeile</p>", "<p>Zweite Zeile</p>"},
|
||||
},
|
||||
{
|
||||
name: "single newline → br",
|
||||
in: "Zeile A\nZeile B",
|
||||
wantContains: []string{"<p>Zeile A<br>", "Zeile B</p>"},
|
||||
},
|
||||
{
|
||||
name: "script tag escaped",
|
||||
in: "Hallo <script>alert(1)</script>",
|
||||
wantContains: []string{"<script>", "</script>"},
|
||||
wantMissing: []string{"<script>", "alert(1)</script>"},
|
||||
},
|
||||
{
|
||||
name: "link injection attempt — javascript: URL is rejected",
|
||||
in: "[click](javascript:alert(1))",
|
||||
wantMissing: []string{`href="javascript:`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := renderMarkdownSafe(tc.in)
|
||||
for _, want := range tc.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, miss := range tc.wantMissing {
|
||||
if strings.Contains(got, miss) {
|
||||
t.Errorf("unexpected %q in %q", miss, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNameExtraction(t *testing.T) {
|
||||
// senderSignature uses DisplayName directly; firstName extraction is
|
||||
// frontend-side. Smoke-test only that DisplayName placeholder lands.
|
||||
sender := models.User{
|
||||
ID: uuid.New(),
|
||||
Email: "max@hlc.com",
|
||||
DisplayName: "Max Mustermann",
|
||||
}
|
||||
sig := senderSignature("de", sender)
|
||||
if !strings.Contains(sig, "Max Mustermann") {
|
||||
t.Errorf("DisplayName not in signature: %q", sig)
|
||||
}
|
||||
if !strings.Contains(sig, "Gesendet von") {
|
||||
t.Errorf("DE prefix missing: %q", sig)
|
||||
}
|
||||
if !strings.Contains(sig, `mailto:max@hlc.com`) {
|
||||
t.Errorf("mailto link missing: %q", sig)
|
||||
}
|
||||
sigEN := senderSignature("en", sender)
|
||||
if !strings.Contains(sigEN, "Sent by") {
|
||||
t.Errorf("EN prefix missing: %q", sigEN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastValidation exercises the cheap guards that fire before any
|
||||
// SQL or SMTP I/O. Constructed with a nil DB so the tests don't need a
|
||||
// connection string. The Send path bails out at validation before touching
|
||||
// db.ExecContext.
|
||||
func TestBroadcastValidation(t *testing.T) {
|
||||
mailSvc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewMailService: %v", err)
|
||||
}
|
||||
svc := NewBroadcastService(nil, mailSvc, nil, nil, NewEmailTemplateService(nil))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in BroadcastInput
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "empty subject",
|
||||
in: BroadcastInput{Subject: "", Body: "x", Recipients: oneRec()},
|
||||
want: ErrBroadcastEmptySubject,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
in: BroadcastInput{Subject: "Hi", Body: " ", Recipients: oneRec()},
|
||||
want: ErrBroadcastEmptyBody,
|
||||
},
|
||||
{
|
||||
name: "no recipients",
|
||||
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nil},
|
||||
want: ErrBroadcastNoRecipients,
|
||||
},
|
||||
{
|
||||
name: "too many recipients",
|
||||
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nRecipients(BroadcastRecipientCap + 1)},
|
||||
want: ErrBroadcastTooManyRecipients,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
in: BroadcastInput{
|
||||
Subject: "Hi",
|
||||
Body: "x",
|
||||
Recipients: []BroadcastRecipient{{
|
||||
UserID: uuid.New(),
|
||||
Email: "not-an-email",
|
||||
}},
|
||||
},
|
||||
want: ErrBroadcastInvalidEmail,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.Send(t.Context(), uuid.New(), tc.in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// Use errors.Is so wrapped errors still match.
|
||||
if !errorIs(err, tc.want) {
|
||||
t.Errorf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errorIs is a tiny shim so the test file doesn't need to import "errors".
|
||||
// (Imports are kept terse on purpose — see existing test files.)
|
||||
func errorIs(have, want error) bool {
|
||||
if have == want {
|
||||
return true
|
||||
}
|
||||
if have == nil || want == nil {
|
||||
return false
|
||||
}
|
||||
// Fall back to message-level matching for fmt.Errorf %w wraps.
|
||||
return strings.Contains(have.Error(), want.Error())
|
||||
}
|
||||
|
||||
func oneRec() []BroadcastRecipient {
|
||||
return []BroadcastRecipient{{
|
||||
UserID: uuid.New(),
|
||||
Email: "anna@hlc.com",
|
||||
DisplayName: "Anna",
|
||||
FirstName: "Anna",
|
||||
}}
|
||||
}
|
||||
|
||||
func nRecipients(n int) []BroadcastRecipient {
|
||||
out := make([]BroadcastRecipient, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, BroadcastRecipient{
|
||||
UserID: uuid.New(),
|
||||
Email: "user@hlc.com",
|
||||
DisplayName: "User",
|
||||
FirstName: "User",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -421,6 +421,13 @@ func hostnameForHelo() string {
|
||||
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
|
||||
// (umlauts) render correctly in every client.
|
||||
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||||
return buildMIMEWithReplyTo(from, fromName, "", to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// buildMIMEWithReplyTo is buildMIME plus an optional Reply-To header.
|
||||
// Bulk-broadcast email uses this so replies route to the human sender even
|
||||
// though From: stays on the SMTP infrastructure address.
|
||||
func buildMIMEWithReplyTo(from, fromName, replyTo, to, subject, htmlBody, textBody string) []byte {
|
||||
boundary := "paliad-mixed-" + randBoundary()
|
||||
fromHeader := from
|
||||
if fromName != "" {
|
||||
@@ -428,6 +435,9 @@ func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||||
}
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
|
||||
if replyTo != "" {
|
||||
fmt.Fprintf(&b, "Reply-To: %s\r\n", replyTo)
|
||||
}
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||||
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
127
internal/services/markdown.go
Normal file
127
internal/services/markdown.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// markdown.go — minimal Markdown → safe HTML converter for broadcast emails.
|
||||
//
|
||||
// Paliad doesn't pull in a third-party Markdown library — the body subset
|
||||
// senders need is small and predictable, so we render it inline. Inputs are
|
||||
// HTML-escaped first; the renderer then re-introduces a small whitelist of
|
||||
// inline tags (<strong>, <em>, <code>, <a>) and block elements (<p>, <ul>,
|
||||
// <li>, <br>) for the patterns it recognises. Anything we don't recognise
|
||||
// stays escaped, so an attacker who tries to slip a <script> tag through
|
||||
// the compose modal sees a literal "<script>" in the rendered email.
|
||||
//
|
||||
// Supported syntax:
|
||||
// - Paragraphs separated by blank lines.
|
||||
// - Single line break inside a paragraph → <br>.
|
||||
// - **bold** → <strong>bold</strong>
|
||||
// - _italic_ or *italic* → <em>italic</em>
|
||||
// - `inline code` → <code>inline code</code>
|
||||
// - [text](https://link) → <a href="...">text</a>
|
||||
// - Lines starting with "- " or "* " → <ul><li>...</li></ul>
|
||||
//
|
||||
// Out-of-scope (intentional, per t-paliad-147 v1):
|
||||
// - Headings, blockquotes, ordered lists, fenced code blocks, images,
|
||||
// tables. These can be added on demand without changing the contract.
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// renderMarkdownSafe converts Markdown to HTML. Output is safe for direct
|
||||
// embedding in an HTML email body: every byte of input is escaped before
|
||||
// the markdown post-processor runs, and the inline rewriter only re-emits
|
||||
// a small whitelist of tags.
|
||||
func renderMarkdownSafe(src string) string {
|
||||
src = strings.ReplaceAll(src, "\r\n", "\n")
|
||||
src = strings.ReplaceAll(src, "\r", "\n")
|
||||
|
||||
// Split into paragraphs on blank lines.
|
||||
paragraphs := strings.Split(src, "\n\n")
|
||||
var out strings.Builder
|
||||
for _, raw := range paragraphs {
|
||||
p := strings.TrimSpace(raw)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// Bullet lists: every line starts with "- " or "* ".
|
||||
if isBulletList(p) {
|
||||
out.WriteString("<ul>\n")
|
||||
for _, line := range strings.Split(p, "\n") {
|
||||
item := strings.TrimSpace(line)
|
||||
if len(item) >= 2 && (item[:2] == "- " || item[:2] == "* ") {
|
||||
item = strings.TrimSpace(item[2:])
|
||||
}
|
||||
out.WriteString(" <li>")
|
||||
out.WriteString(renderInline(item))
|
||||
out.WriteString("</li>\n")
|
||||
}
|
||||
out.WriteString("</ul>\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// Plain paragraph. Single-newline within → <br>.
|
||||
lines := strings.Split(p, "\n")
|
||||
out.WriteString("<p>")
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
out.WriteString("<br>\n")
|
||||
}
|
||||
out.WriteString(renderInline(strings.TrimSpace(line)))
|
||||
}
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isBulletList(p string) bool {
|
||||
for _, line := range strings.Split(p, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if len(t) < 2 {
|
||||
return false
|
||||
}
|
||||
if t[:2] != "- " && t[:2] != "* " {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var (
|
||||
mdLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^\s)]+)\)`)
|
||||
mdBoldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
mdItalRE1 = regexp.MustCompile(`(^|[^\w])_([^_]+)_($|[^\w])`)
|
||||
mdItalRE2 = regexp.MustCompile(`(^|[^\w*])\*([^*]+)\*($|[^\w*])`)
|
||||
mdCodeRE = regexp.MustCompile("`([^`]+)`")
|
||||
)
|
||||
|
||||
// renderInline applies inline markdown to one line. The input is escaped
|
||||
// first; replacements re-emit whitelisted tags.
|
||||
func renderInline(line string) string {
|
||||
s := html.EscapeString(line)
|
||||
// Order matters: links first (they wrap text+URL), then bold (which is
|
||||
// **…** and would otherwise be split by the italic *…* rule), then
|
||||
// italics, then code.
|
||||
s = mdLinkRE.ReplaceAllStringFunc(s, func(m string) string {
|
||||
matches := mdLinkRE.FindStringSubmatch(m)
|
||||
if len(matches) != 3 {
|
||||
return m
|
||||
}
|
||||
text, url := matches[1], matches[2]
|
||||
// URL is already escaped by html.EscapeString above; href quoting
|
||||
// also needs the &-form so screen readers don't choke.
|
||||
return fmt.Sprintf(`<a href="%s">%s</a>`, url, text)
|
||||
})
|
||||
s = mdBoldRE.ReplaceAllString(s, `<strong>$1</strong>`)
|
||||
s = mdItalRE1.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
||||
s = mdItalRE2.ReplaceAllString(s, `$1<em>$2</em>$3`)
|
||||
s = mdCodeRE.ReplaceAllString(s, `<code>$1</code>`)
|
||||
return s
|
||||
}
|
||||
|
||||
// escapeHTML is a thin alias used by senderSignature so the broadcast file
|
||||
// doesn't need to import html directly.
|
||||
func escapeHTML(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
757
internal/services/paliadin.go
Normal file
757
internal/services/paliadin.go
Normal 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
|
||||
269
internal/services/paliadin_prompt.go
Normal file
269
internal/services/paliadin_prompt.go
Normal 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.
|
||||
`)
|
||||
}
|
||||
165
internal/services/paliadin_test.go
Normal file
165
internal/services/paliadin_test.go
Normal 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:])
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user