Compare commits
200 Commits
mai/fritz/
...
mai/noethe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a35cad09f | ||
|
|
6058d21ce6 | ||
|
|
52caba51ec | ||
|
|
1faffb682e | ||
|
|
4b681792ab | ||
|
|
236bb3270e | ||
|
|
4670cd660a | ||
|
|
1e97eccaed | ||
|
|
3a41acee07 | ||
|
|
de4e133f03 | ||
|
|
0c12644563 | ||
|
|
5d9c62d858 | ||
|
|
188d8ec9ba | ||
|
|
d5a01e6682 | ||
|
|
02d4ac2f4e | ||
|
|
ae1cba4e24 | ||
|
|
1e23745792 | ||
|
|
1782dfa910 | ||
|
|
936aca5925 | ||
|
|
0b47343aa3 | ||
|
|
f31307afcb | ||
|
|
aa112d2589 | ||
|
|
dc35d2da69 | ||
|
|
d2790a0461 | ||
|
|
97d49898b7 | ||
|
|
5b08bfcb96 | ||
|
|
fc048c578e | ||
|
|
d0e8c995fe | ||
|
|
dd0cee226d | ||
|
|
6fcf34a3e3 | ||
|
|
e824898a6d | ||
|
|
2f27620a5b | ||
|
|
75dc842b8e | ||
|
|
6224898f9e | ||
|
|
4ecea7a4bb | ||
|
|
a3052eb085 | ||
|
|
75cfe914ce | ||
|
|
34e82ead06 | ||
|
|
2cd7266198 | ||
|
|
ba2408eb51 | ||
|
|
dba8ad3fdd | ||
|
|
d4c129f0d6 | ||
|
|
df04e500f7 | ||
|
|
0d1a7ba886 | ||
|
|
e9e7d5c27c | ||
|
|
282e0bb237 | ||
|
|
142edca401 | ||
|
|
caa76d2925 | ||
|
|
fdbbc74c15 | ||
|
|
e2907db760 | ||
|
|
097e21c8db | ||
|
|
2d6ea3ee33 | ||
|
|
614f9af753 | ||
|
|
6008d36a13 | ||
|
|
4bab520119 | ||
|
|
c06be27cce | ||
|
|
ef78f59d25 | ||
|
|
ac15911e4f | ||
|
|
f1889fabcf | ||
|
|
9350cd0e87 | ||
|
|
3368aa58a6 | ||
|
|
aec6cf6104 | ||
|
|
073af975f7 | ||
|
|
8c58783cd3 | ||
|
|
3a41aa9209 | ||
|
|
6ef14ddc39 | ||
|
|
9339148ef5 | ||
|
|
e87929885d | ||
|
|
1df1bc7e40 | ||
|
|
b23a08867b | ||
|
|
7be8511833 | ||
|
|
06bd276a9c | ||
|
|
f84bce1359 | ||
|
|
afe4fc2efe | ||
|
|
7614748243 | ||
|
|
7c751617e5 | ||
|
|
609da9e86b | ||
|
|
7daa70aaad | ||
|
|
05d14d5e5a | ||
|
|
925a377c8b | ||
|
|
7935fee7bf | ||
|
|
be2150c17d | ||
|
|
5893c45e5e | ||
|
|
3e1f4eee4b | ||
|
|
e75a71fb34 | ||
|
|
9579032f94 | ||
|
|
97a412498d | ||
|
|
319221ff83 | ||
|
|
4c47819da8 | ||
|
|
db3514c4db | ||
|
|
a0d1e77ef2 | ||
|
|
d519363c8d | ||
|
|
82faa3d8bd | ||
|
|
a80652a085 | ||
|
|
f820aa8316 | ||
|
|
5df4285e1d | ||
|
|
028423b32f | ||
|
|
1d7c7d7246 | ||
|
|
0f87d73b1b | ||
|
|
da971a7466 | ||
|
|
e6067c74db | ||
|
|
e4110cf2db | ||
|
|
68c56ea920 | ||
|
|
0c8a2f1a95 | ||
|
|
56a3dc961e | ||
|
|
e92c56b5f8 | ||
|
|
f7908f03ad | ||
|
|
f62bf9f8fb | ||
|
|
dd139a3536 | ||
|
|
01fa4b1287 | ||
|
|
bb035558be | ||
|
|
f952fb85c3 | ||
|
|
b78941e293 | ||
|
|
55c93c9de3 | ||
|
|
f90bfeda9b | ||
|
|
024841129f | ||
|
|
db4279d148 | ||
|
|
552c9200bc | ||
|
|
befa41c00e | ||
|
|
aeeded7e21 | ||
|
|
4e1d311a9c | ||
|
|
1061685981 | ||
|
|
a5f7b5009b | ||
|
|
b59e44616d | ||
|
|
fb608321ca | ||
|
|
35f307d61d | ||
|
|
8412328dec | ||
|
|
2201c6da73 | ||
|
|
438e73fd13 | ||
|
|
597d76e21c | ||
|
|
8bdebe9bc1 | ||
|
|
d53cc3553c | ||
|
|
b9824dd86f | ||
|
|
397a9b1854 | ||
|
|
f4aa2033f9 | ||
|
|
efaa7787af | ||
|
|
c6cdd2c855 | ||
|
|
fc7192c115 | ||
|
|
8d714dd95e | ||
|
|
0b4de1c645 | ||
|
|
2af4bf1f88 | ||
|
|
9184e9b0ef | ||
|
|
7b66c4d035 | ||
|
|
e6937d232e | ||
|
|
6506864730 | ||
|
|
ab2530ff44 | ||
|
|
8cc8435d2e | ||
|
|
c81ca6a12a | ||
|
|
0f835b6c59 | ||
|
|
905e743281 | ||
|
|
215a1ceeda | ||
|
|
e4adc39833 | ||
|
|
3dffce7a0d | ||
|
|
d8b84d0c58 | ||
|
|
d24f73358c | ||
|
|
52ee319fd8 | ||
|
|
dc7c807725 | ||
|
|
1eb43ceb6b | ||
|
|
99f08e3863 | ||
|
|
dd4f563212 | ||
|
|
95f6f03cda | ||
|
|
fdde9eb754 | ||
|
|
cda4b4083d | ||
|
|
b516201110 | ||
|
|
956ff10e4d | ||
|
|
5c263102e3 | ||
|
|
f44ee0af0f | ||
|
|
bfc48b1420 | ||
|
|
5cb7f76160 | ||
|
|
8b76d0c8fa | ||
|
|
9cd05e7c59 | ||
|
|
5598aef074 | ||
|
|
16fe5763f3 | ||
|
|
18faf81f58 | ||
|
|
aeaba66892 | ||
|
|
a61c1490e3 | ||
|
|
544bb63684 | ||
|
|
2d06cdf20e | ||
|
|
f8d8ea591d | ||
|
|
77d664c5cc | ||
|
|
8cf95761d0 | ||
|
|
d41fc49809 | ||
|
|
1eebf2fc44 | ||
|
|
fb1a709bb8 | ||
|
|
e2e1381395 | ||
|
|
0d54da1d5b | ||
|
|
deef5aaff5 | ||
|
|
bc47d78d97 | ||
|
|
07a1c17861 | ||
|
|
2247c0707d | ||
|
|
93c4453ce5 | ||
|
|
a42322de3f | ||
|
|
abc395fcfa | ||
|
|
747d85fe49 | ||
|
|
6c41550945 | ||
|
|
fb6a07f4b7 | ||
|
|
10b3426086 | ||
|
|
4ebbf2c1af | ||
|
|
b3401ec8ac | ||
|
|
7d1ddb9b84 |
@@ -46,7 +46,12 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
|
||||
| `GITEA_TOKEN` | optional | Gitea API token for the private file proxy (Downloads) |
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion) which is deferred per m's 2026-04-16 decision. Do not set. |
|
||||
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
|
||||
| `PALIADIN_SESSION_PREFIX` | optional (default `paliad-paliadin`) | Prefix for the per-user tmux session names the Paliadin service uses (t-paliad-155). Each Paliad user gets their own session named `<prefix>-<userid8>` (first 8 hex chars of the user's UUID); conversation history accumulates per visit, `ResetSession` kills the session entirely. The persona + response protocol now live in `~/.claude/skills/paliadin/SKILL.md` (installed via `scripts/install-paliadin-skill`) — no in-process system prompt is sent. |
|
||||
| `PALIADIN_REMOTE_CWD` | shim env (default `/home/m/dev/paliad`) | Working directory `paliadin-shim` uses when spawning the long-lived `claude` pane on mRiver. Must be the paliad repo root so claude picks up `.mcp.json` (project-scoped Supabase MCP); without it, the SKILL.md SQL recipes have no DB tool. Set on mRiver only — paliad's Go side never reads this. |
|
||||
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
|
||||
|
||||
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
|
||||
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
|
||||
|
||||
> *Note on `DATABASE_URL`:* "Work without DB" ≠ "ungated". All knowledge-platform routes (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) are still behind the auth gate (302 to `/login` for anon visitors); only `/`, `/login`, `/logout`, and `/assets/*` are public. The `gateOnboarded` middleware additionally blocks unonboarded users from app pages but does NOT gate the knowledge-platform pages.
|
||||
|
||||
@@ -50,7 +50,7 @@ worker:
|
||||
max_workers: 5
|
||||
persistent: true
|
||||
head:
|
||||
name: "maria"
|
||||
name: "paliadin"
|
||||
max_loops: 50
|
||||
infinity_mode: false
|
||||
max_idle_duration: 2h0m0s
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /paliad ./cmd/server
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache ca-certificates openssh-client
|
||||
WORKDIR /app
|
||||
COPY --from=backend /paliad /app/paliad
|
||||
COPY --from=frontend /app/frontend/dist /app/dist
|
||||
|
||||
@@ -2,10 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
// Embed Go's IANA tz database into the binary so time.LoadLocation works
|
||||
@@ -157,7 +162,54 @@ func main() {
|
||||
EmailTemplate: emailTemplateSvc,
|
||||
Link: services.NewLinkService(pool),
|
||||
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
|
||||
Approval: services.NewApprovalService(pool, users),
|
||||
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
}
|
||||
|
||||
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else: DisabledPaliadinService (handlers still 404 for non-owners
|
||||
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
|
||||
// which surfaces as a friendly error).
|
||||
//
|
||||
// All three implement services.Paliadin; the per-request handler
|
||||
// gate (requirePaliadinOwner) is unchanged and applies to every
|
||||
// backend.
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
// Without this wiring, the policies and request tables exist but no
|
||||
// mutation path consults them — paliad behaves as before.
|
||||
deadlineSvc.SetApprovalService(svcBundle.Approval)
|
||||
appointmentSvc.SetApprovalService(svcBundle.Approval)
|
||||
// v3 (t-paliad-133): wire EventCategoryService and cross-link
|
||||
// it into DeadlineSearchService so ?event_category_slug= can
|
||||
// resolve to a concept-id allow-list during search.
|
||||
@@ -190,3 +242,134 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPaliadinRemoteConfig assembles a RemotePaliadinConfig from
|
||||
// environment variables, materialising the SSH private key and
|
||||
// known_hosts blobs into chmod-600/644 tmpfiles for OpenSSH to read.
|
||||
//
|
||||
// The blobs travel as Dokploy secrets (multi-line env vars). We never
|
||||
// persist them to disk — tmpfiles live for the process lifetime in
|
||||
// /tmp and disappear on container restart. Re-creating them every boot
|
||||
// is fine; the keys themselves rotate independently via Dokploy
|
||||
// secret updates.
|
||||
//
|
||||
// Required: PALIADIN_REMOTE_HOST, PALIADIN_SSH_PRIVATE_KEY, PALIADIN_KNOWN_HOSTS.
|
||||
// Optional: PALIADIN_REMOTE_USER (default "m"), PALIADIN_REMOTE_PORT
|
||||
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
|
||||
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
|
||||
cfg := services.RemotePaliadinConfig{
|
||||
SSHHost: host,
|
||||
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
|
||||
SSHPort: 22022,
|
||||
SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
|
||||
}
|
||||
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return cfg, fmt.Errorf("PALIADIN_REMOTE_PORT %q: not a valid port", p)
|
||||
}
|
||||
cfg.SSHPort = n
|
||||
}
|
||||
|
||||
// Dokploy stores compose env vars in a single-line .env file: multi-line
|
||||
// PEM bodies get truncated to the first line. Base64-encode the
|
||||
// private key in the secret to survive that round-trip; here we
|
||||
// detect base64 vs raw PEM and decode either way.
|
||||
keyBlob, err := decodePaliadinPrivateKey(os.Getenv("PALIADIN_SSH_PRIVATE_KEY"))
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
|
||||
}
|
||||
keyPath, err := writeSecretFile("paliadin-id_ed25519-", keyBlob, 0o600)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
|
||||
}
|
||||
if keyPath == "" {
|
||||
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_SSH_PRIVATE_KEY empty")
|
||||
}
|
||||
cfg.SSHKeyPath = keyPath
|
||||
|
||||
knownHostsPath, err := writeSecretFile("paliadin-known_hosts-", os.Getenv("PALIADIN_KNOWN_HOSTS"), 0o644)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("PALIADIN_KNOWN_HOSTS: %w", err)
|
||||
}
|
||||
if knownHostsPath == "" {
|
||||
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_KNOWN_HOSTS empty")
|
||||
}
|
||||
cfg.KnownHostsPath = knownHostsPath
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// decodePaliadinPrivateKey accepts either a raw PEM (multi-line) or a
|
||||
// base64-encoded PEM. Returns the raw PEM bytes ready to write to a
|
||||
// keyfile. Empty input → ("", nil) so the caller can distinguish
|
||||
// "secret not set" from "decode failed".
|
||||
//
|
||||
// Why base64: Dokploy stores compose env vars in a one-line-per-key
|
||||
// .env file, which silently truncates multi-line values to their first
|
||||
// line. Empirically, a multi-line `-----BEGIN OPENSSH PRIVATE KEY-----`
|
||||
// arrived inside the container as just the BEGIN header (36 bytes).
|
||||
// Base64-encoding the key in the Dokploy secret survives that
|
||||
// round-trip. We still accept raw PEM for local-dev convenience.
|
||||
func decodePaliadinPrivateKey(blob string) (string, error) {
|
||||
blob = strings.TrimSpace(blob)
|
||||
if blob == "" {
|
||||
return "", nil
|
||||
}
|
||||
// Raw PEM: starts with ----- and contains a newline. Use as-is.
|
||||
if strings.HasPrefix(blob, "-----") && strings.Contains(blob, "\n") {
|
||||
return blob + "\n", nil
|
||||
}
|
||||
// Otherwise treat as base64. Strip any whitespace OpenSSH keygen
|
||||
// helpers might insert (line breaks every 64 chars in some tools).
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, blob)
|
||||
decoded, err := base64.StdEncoding.DecodeString(clean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("not raw PEM (no newline) and base64 decode failed: %w", err)
|
||||
}
|
||||
out := string(decoded)
|
||||
if !strings.HasPrefix(out, "-----BEGIN") {
|
||||
return "", fmt.Errorf("decoded body does not look like a PEM key (no -----BEGIN prefix)")
|
||||
}
|
||||
if !strings.HasSuffix(out, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeSecretFile writes blob to a tmpfile with the given mode and
|
||||
// returns its path. Returns ("", nil) when blob is empty so callers
|
||||
// can distinguish "not set" from real I/O errors.
|
||||
func writeSecretFile(prefix, blob string, mode os.FileMode) (string, error) {
|
||||
if blob == "" {
|
||||
return "", nil
|
||||
}
|
||||
f, err := os.CreateTemp("", prefix+"*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := f.WriteString(blob); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Chmod(f.Name(), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
func cmpOr(s, fallback string) string {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -20,5 +20,19 @@ services:
|
||||
- SMTP_FROM=${SMTP_FROM}
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME}
|
||||
- SMTP_USE_TLS=${SMTP_USE_TLS}
|
||||
# Paliadin remote routing (t-paliad-151). When PALIADIN_REMOTE_HOST
|
||||
# is set, paliad forwards each turn to mRiver via SSH on port 22022.
|
||||
# The container reaches mRiver over Tailscale via mLake's host-side
|
||||
# tailscale0 + Docker source NAT — no network_mode override needed
|
||||
# (verified Phase A.5: a plain alpine container on Dokploy's
|
||||
# default bridge SSHs to mriver:22022 in 3 s, source IP NAT'd to
|
||||
# mLake's tailnet IP, matches the from="100.99.98.201" clause on
|
||||
# mRiver's authorized_keys).
|
||||
# PRIVATE_KEY and KNOWN_HOSTS are multi-line Dokploy secrets.
|
||||
- PALIADIN_REMOTE_HOST=${PALIADIN_REMOTE_HOST}
|
||||
- PALIADIN_REMOTE_PORT=${PALIADIN_REMOTE_PORT}
|
||||
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER}
|
||||
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
|
||||
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
417
docs/audit-upc-rop-deadlines-2026-05-08.md
Normal file
417
docs/audit-upc-rop-deadlines-2026-05-08.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Audit — UPC Rules of Procedure deadline coverage in paliad
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-159 (Gitea m/paliad#14, RoP audit aspect)
|
||||
**Mode:** read-only research; produces a gap-list, not migrations.
|
||||
|
||||
Companion to `docs/audit-fristenrechner-completeness-2026-04-30.md`. That audit drove from youpc's existing deadline corpus (~64 RoP codes referenced); this one drives **from the UPC Rules of Procedure themselves**, taking a frequency-weighted slice of what a real INF/REV/APP proceeding has to track.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
**RoP sections audited (8 sections):**
|
||||
|
||||
| Code | Section | RoP rules in scope (deadline-creating) |
|
||||
|---|---|---|
|
||||
| A | Pleadings — Infringement (UPC_INF) | R.13, R.17, R.19, R.23, R.24, R.25, R.29.a/b/c/d/e, R.30, R.32 |
|
||||
| B | Pleadings — Revocation + CCR + DNI (UPC_REV) | R.42, R.44, R.49.1, R.49.2.a, R.49.2.b, R.50, R.51, R.52, R.55, R.56, R.61, R.63, R.65, R.67, R.68, R.69, R.70 |
|
||||
| C | Provisional measures + evidence preservation | R.197.3, R.198, R.205, R.207.6, R.207.9, R.211.2, R.213 |
|
||||
| D | Damages + lay-open books | R.125, R.131.2, R.137, R.139, R.141, R.142.2, R.142.3 |
|
||||
| E | Decisions, costs, default judgment | R.111, R.118.4, R.151, R.157, R.221.1 |
|
||||
| F | Appeals | R.220.1.a/b/c, R.220.2, R.220.3, R.221.1, R.224.1.a/b, R.224.2.a/b, R.229.2, R.234.1, R.235.1, R.235.2, R.237, R.238.1, R.238.2, R.245.2 |
|
||||
| G | Re-establishment, case-management, miscellaneous | R.262.2, R.295, R.320, R.321.3, R.331, R.333.2, R.353 |
|
||||
| H | Oral-hearing prep + translations | R.109.1, R.109.4, R.109.5 |
|
||||
|
||||
**Out of scope here** (deferred to a follow-up audit):
|
||||
- EPO opposition + Beschwerde (Art. 99 / R.99 EPÜ — paliad models these via EPA_OPP / EPA_APP, separate concern)
|
||||
- DPMA / BPatG / BGH families (modelled via DE_*, DPMA_* — separate concern)
|
||||
- ZPO civil-procedure deadlines around stay/severance
|
||||
- UPC court-fee deadlines (Art. 70 UPCA / R.370)
|
||||
- R.32(2) extensions of time (judge-set, no fixed duration)
|
||||
- Judicial discretion items without a fixed period (stays under R.295, choice-of-language under R.323, joinder, intervention R.313)
|
||||
- "Notice of intent to defend" (1mo, R.23 reaction): explicitly excluded by migration 052 §2 — no UPC rule exists for that concept.
|
||||
|
||||
---
|
||||
|
||||
**Authoritative source for RoP text:** the youpc Postgres `data.laws_contents` table (law_type = `UPCRoP`, English language). Cross-checked the in-scope rules against the actual rule text rather than relying on prior summaries — this is what surfaced the two duration bugs in §B (R.49.1 and R.52). All other high-frequency durations (R.23, R.29.a/b/c/d/e, R.32.1/3, R.43.3, R.56.1/3/4, R.137.2, R.139, R.142.2/3, R.151, R.220.2, R.221.1, R.224.1.a/b, R.224.2.a/b, R.235.1/2, R.238.1/2) were cross-checked and confirmed.
|
||||
|
||||
## 2. Methodology
|
||||
|
||||
For every RoP rule in the in-scope list:
|
||||
1. Identify the trigger event (what starts the period).
|
||||
2. Identify the duration + unit (calendar days / months / before-or-after).
|
||||
3. Look up paliad's rule library. Three lookup paths:
|
||||
- `paliad.deadline_rules` (the proceeding-tree shape used by Fristenrechner Pathway A — "pick proceeding type, see whole timeline").
|
||||
- `paliad.deadline_concepts` × `paliad.event_category_concepts` (the cascade shape used by Pathway B — "pick what just landed in the CMS, see what reacts").
|
||||
- `paliad.trigger_events` (the youpc-style event list, 90+ rows, used by the search/autocomplete surface).
|
||||
4. Assign a status: `present-correct` / `present-wrong` / `partial` / `missing` / `n/a`.
|
||||
|
||||
Status definitions:
|
||||
- **present-correct** — paliad has a row with the right RoP code, duration, anchor, and (where relevant) primary_party.
|
||||
- **present-wrong** — paliad has a row that fires for this rule but with a wrong duration / anchor / condition.
|
||||
- **partial** — paliad has the rule for one branch (e.g. proactive only, or one party only) but is missing the symmetric branch.
|
||||
- **missing** — no rule, no concept-card, no trigger-event entry covers this.
|
||||
- **n/a** — RoP rule doesn't create a tracked deadline (e.g. judge sets it ad hoc, or rule is purely structural).
|
||||
|
||||
Frequency tag (column "Freq" in §3 tables):
|
||||
- **★★★** — every UPC infringement / revocation / appeal will hit this.
|
||||
- **★★** — common (most cases at some stage).
|
||||
- **★** — specialist (PI, saisie, damages-only, rehearing).
|
||||
|
||||
The 2026-04-30 audit (§3, §4) already enumerated the core youpc gaps. Where a finding here overlaps that audit, I cite it (`see 2026-04-30 §X`) and avoid restating.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
### Section A — Infringement pleadings (R.13–R.32)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.13 | Klageerhebung (filing of SoC) | — (anchor event, duration 0) | `inf.soc` | present-correct | ★★★ | UPC_INF root. |
|
||||
| R.17 | Decision on language of proceedings | judge-discretion | n/a (no auto-deadline) | n/a | ★ | Court-set; no Fristenrechner row needed. |
|
||||
| R.19 | Service of SoC → Preliminary Objection | 1 month | **missing** | missing | ★★ | No `inf.prelim_objection` row in UPC_INF. The cascade has no "Vorgängige Einrede" leaf either. Trigger event 68 (`preliminary_objection`) exists in `paliad.trigger_events` but has no rule attached. |
|
||||
| R.20.2 | PO → Reply to PO | 14 days | missing | missing | ★ | Fringe but real — defendant's PO triggers a 14d response window. |
|
||||
| R.23 | Service of SoC → Statement of Defence | 3 months | `inf.sod` (RoP.023) | present-correct | ★★★ | Rule code format is `RoP.023` — was normalised since 2026-04-30 §4.3. |
|
||||
| R.24 / R.25 | SoD with CCR | (CCR rolled into SoD, 3mo) | `cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr` (cascade leaf) | present-correct | ★★★ | Cascade leaf maps to `defence-to-counterclaim-for-revocation` concept. |
|
||||
| R.29.a | SoD-with-CCR served → Defence to CCR + Reply to SoD | 2 months | `inf.def_to_ccr` (RoP.029.a) | present-correct | ★★★ | |
|
||||
| R.29.b | SoD-without-CCR served → Reply to SoD | 2 months | `inf.reply` (RoP.029.b, alt RoP.029.a) | present-correct | ★★★ | Adaptive: alt branch flips for with-CCR. Migration 050 wired the bilateral backfill. |
|
||||
| R.29.c | Reply served → Rejoinder | 1 month | `inf.rejoin` (RoP.029.c, alt RoP.029.d) | present-correct | ★★★ | Adaptive. |
|
||||
| R.29.d | Reply-to-defence-to-CCR served → Reply to that | 2 months | `inf.reply_def_ccr` (RoP.029.d) | present-correct | ★★ | |
|
||||
| R.29.e | That reply served → Rejoinder | 1 month | `inf.rejoin_reply_ccr` (RoP.029.e) | present-correct | ★★ | |
|
||||
| R.30.1 | Defendant filing Application to Amend (with CCR) | 0 (ride along with SoD) | `inf.app_to_amend` (RoP.030.1) | present-correct | ★★ | |
|
||||
| R.32.1 | Application to Amend served → Defence to Amend | 2 months | `inf.def_to_amend` (RoP.032.1) | present-correct | ★★ | |
|
||||
| R.32.3 | Defence-to-Amend served → Reply | 1 month | `inf.reply_def_amd` (RoP.032.3) | present-correct | ★★ | |
|
||||
| R.32.3 | Reply-on-Amend served → Rejoinder | 1 month | `inf.rejoin_amd` (RoP.032.3) | present-correct | ★★ | Same code, different rule row (rejoinder branch). Reused code is intentional. |
|
||||
|
||||
**Section A net:** 13 rules covered correctly. **2 missing** (R.19 Preliminary Objection 1mo, R.20.2 Reply to PO 14d).
|
||||
|
||||
---
|
||||
|
||||
### Section B — Revocation + CCR + DNI (R.42–R.70)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.42 | Filing of Nichtigkeitsklage (REV) | 0 anchor | `rev.app` | present-correct | ★★★ | UPC_REV root. |
|
||||
| **R.49.1** | Service of REV → Defence to Revocation | **2 months** | `rev.defence` (**3 months**, RoP.49.1) | **present-wrong (DURATION + rule_code)** | ★★★ | **Confirmed via youpc UPCRoP.049.1 text:** *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* paliad seeded 3 months — copy-paste from R.23 (UPC_INF Defence which is correctly 3mo). **High-impact bug — REV Defence is the most-used revocation deadline.** Rule_code also drift (`RoP.49.1` → `RoP.049.1`). |
|
||||
| R.49.2.a | REV → Application to Amend | 0 (rides along with Defence) | `rev.app_to_amend` (RoP.049.2.a) | present-correct | ★★ | |
|
||||
| R.49.2.b | REV → Counterclaim for Infringement (CCI) | 0 (rides along) | `rev.cc_inf` (RoP.049.2.b) | present-correct | ★★ | |
|
||||
| **R.51** | Defence to Revocation served → Reply | 2 months | `rev.reply` (2mo, no rule_code) | present-wrong (rule_code only) | ★★★ | **Confirmed via youpc UPCRoP.051.p1 text:** *"Within two months of service of the Defence to revocation the claimant may lodge a Reply…"* Duration correct. Rule_code is NULL — add `RoP.051`. (Note: my initial draft cited "R.50" for this; the actual rule is R.51 — R.50 is "Contents of the Defence to revocation" with no duration.) |
|
||||
| **R.52** | Reply served → Rejoinder | **1 month** | `rev.rejoin` (**2 months**, no rule_code) | **present-wrong (DURATION + rule_code)** | ★★★ | **Confirmed via youpc UPCRoP.052.p1 text:** *"Within one month of the service of the Reply the defendant may lodge a Rejoinder…"* paliad seeded 2 months — bug. Rejoinder is symmetric with R.29.c (UPC_INF rejoinder, also 1mo). Add rule_code `RoP.052`. **Second high-impact bug.** |
|
||||
| R.43.3 | Application-to-Amend served → Defence to Amend (in REV) | 2 months | `rev.def_to_amend` (RoP.043.3) | present-correct | ★★ | |
|
||||
| R.32.3 | Reply on Amend (in REV) | 1 month | `rev.reply_def_amd` (RoP.032.3) | present-correct | ★★ | |
|
||||
| R.32.3 | Rejoinder on Amend (in REV) | 1 month | `rev.rejoin_amd` (RoP.032.3) | present-correct | ★★ | |
|
||||
| R.56.1 | CCI served → Defence to CCI | 2 months | `rev.def_cci` (RoP.056.1) | present-correct | ★★ | |
|
||||
| R.56.3 | Defence-to-CCI served → Reply | 1 month | `rev.reply_def_cci` (RoP.056.3) | present-correct | ★★ | |
|
||||
| R.56.4 | Reply served → Rejoinder | 1 month | `rev.rejoin_cci` (RoP.056.4) | present-correct | ★★ | |
|
||||
| R.61 | Pre-CCI standalone Counterclaim (CCR-only proceedings) | n/a (overlap with CCR mechanics) | n/a | n/a | ★ | UPC_REV with CCI already covers this; R.61 is a structural cross-ref. |
|
||||
| R.63 | Filing of Application for DNI | 0 anchor | **missing** | missing | ★ | No UPC_DNI proceeding type exists. Cascade has no DNI leaf. |
|
||||
| R.67.1 | DNI served → Defence to DNI | 2 months | **missing** | missing | ★ | |
|
||||
| R.69.1 | Defence-to-DNI served → Reply | 1 month | **missing** | missing | ★ | |
|
||||
| R.69.2 | Reply served → Rejoinder | 1 month | **missing** | missing | ★ | |
|
||||
| R.70 | Application of UPC_INF rules to DNI | 0 (cross-ref) | n/a | n/a | ★ | DNI inherits all of UPC_INF's chain after the initial 4 rules. |
|
||||
|
||||
**Section B net:** 8 rules covered correctly, **2 high-impact duration bugs** (R.49.1 Defence-to-Revocation 3mo seeded but RoP says 2mo; R.52 Rejoinder 2mo seeded but RoP says 1mo), **2 rule_code drift** (R.51 NULL, R.52 NULL — fixing alongside the duration corrections), **4 missing** (DNI family — R.63, R.67.1, R.69.1, R.69.2). Both duration bugs surfaced from cross-referencing the authoritative RoP text via `data.laws_contents` (the youpc law database) — they were invisible in the rule-code-format-only review.
|
||||
|
||||
DNI is a low-frequency proceeding (zero filings in published UPC orders 2026-Q1) — flag, but Tier 3 priority.
|
||||
|
||||
---
|
||||
|
||||
### Section C — Provisional measures + evidence preservation (R.190–R.213)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.197.3 | Saisie order served on respondent → Application for review | 30 days | **missing** | missing | ★ | Trigger event 65 (`request_for_review_of_the_order_to_preserve_evidence`) exists; no rule attached. |
|
||||
| R.198 | Saisie executed → Start proceedings on the merits | **31 calendar days OR 20 working days, whichever is longer** | **missing** | missing | ★ | Requires arithmetic primitive paliad doesn't have (see 2026-04-30 §5.1). Trigger event 81 (`start_of_proceedings_on_the_merits`) exists. |
|
||||
| R.205 | Application for PI filed | 0 anchor | `pi.app` | present-correct | ★★ | UPC_PI root. |
|
||||
| R.207.6.a | Notification of deficiency in PI application | 14 days | **missing** | missing | ★★ | Registry-correction family. Trigger event 71 (`notification_by_the_registry_to_correct_deficiencies`) exists; no rule. |
|
||||
| R.207.9 | PI filed → Renewal of protective letter | 6 months | **missing** | missing | ★ | Trigger event 46 (`renewal_of_protective_letter`) exists; no rule. |
|
||||
| R.211.2 | PI granted ex parte → Inter partes hearing | judge-set (typ. ≤30d) | `pi.response` (court-set, duration=0) | present-correct (court-set) | ★★ | Modelled as duration=0 with parent → UI shows "vom Gericht gesetzt". Correct shape. |
|
||||
| R.211.4 | PI granted → Service on respondent | judge-set | n/a | n/a | ★★ | No fixed period. |
|
||||
| R.213 | PI granted → Start proceedings on the merits | **31 calendar days OR 20 working days, whichever is longer** | **missing** | missing | ★★ | Same arithmetic primitive as R.198. |
|
||||
| R.196.5 | Saisie review → Damages application by respondent | 31d / 20wd | n/a | n/a | ★ | Conditional on PI being revoked; specialist. |
|
||||
|
||||
**Section C net:** PI happy-path covered. **5 missing** (R.197.3, R.198, R.207.6.a, R.207.9, R.213). The two "31d / 20wd whichever is longer" rules are blocked on a missing arithmetic primitive (see §5.1).
|
||||
|
||||
---
|
||||
|
||||
### Section D — Damages + lay-open books (R.125–R.144)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.125 | Decision on the merits incl. damages-in-principle | 0 anchor | n/a | n/a | ★★ | Structural — triggers R.131. |
|
||||
| R.131.2 | Final decision on validity → Application for damages, indication | judge-set (typ. by court order) | `damages.app` (duration=0, court-set shape) | present-correct | ★★ | Trigger event 82 covers the indication. |
|
||||
| R.137.2 | Application for damages served → Defence | 2 months | `damages.defence` (RoP.137.2) | present-correct | ★★ | |
|
||||
| R.139 | Defence served → Reply | 1 month | `damages.reply` (RoP.139) | present-correct | ★★ | |
|
||||
| R.139 | Reply served → Rejoinder | 1 month | `damages.rejoin` (RoP.139) | present-correct | ★★ | |
|
||||
| R.141 | Order to lay open books filed | 0 anchor | `disc.app` (UPC_DISCOVERY) | present-correct | ★★ | |
|
||||
| R.142.2 | Order served → Defence | 2 months | `disc.defence` (RoP.142.2) | present-correct | ★★ | |
|
||||
| R.142.3 | Defence served → Reply | 14 days | `disc.reply` (RoP.142.3) | present-correct | ★★ | |
|
||||
| R.142.3 | Reply served → Rejoinder | 14 days | `disc.rejoin` (RoP.142.3) | present-correct | ★★ | |
|
||||
| R.144 | Final decision on damages quantum | 0 anchor (court event) | missing | partial | ★ | No `damages.decision` row analogous to `inf.decision`. UPC_DAMAGES tree ends at `damages.rejoin`. |
|
||||
| R.118.4 | Final decision on validity → Application for orders consequential | 2 months | **missing** | missing | ★★ | Trigger event 36 exists; no rule attached. Common after EPO-or-CD validity ruling. |
|
||||
|
||||
**Section D net:** Damages happy-path covered. **2 missing** (R.118.4 application for consequential orders, R.144 damages decision tree-end). Lay-open books covered cleanly.
|
||||
|
||||
---
|
||||
|
||||
### Section E — Decisions, costs, default judgment (R.111–R.157)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.111 | Decision on the merits delivered | 0 anchor | `inf.decision` / `rev.decision` / `app.decision` | present-correct | ★★★ | Tree-end events. |
|
||||
| R.118 | Decision on validity (final) | 0 anchor | (covered as `inf.decision`) | present-correct | ★★★ | |
|
||||
| R.118.4 | Final decision on validity → Application for orders consequential | 2 months | missing | missing | ★★ | Repeated from §D — tracked as a single gap. |
|
||||
| R.118.5 | Default judgment served → Set-aside ("Einspruch") | **missing in UPC** | n/a | n/a | ★ | UPC has no German-style Versäumnisurteil-Einspruch; closest is R.355 review of contumacy. Concept `versaeumnisurteil-einspruch` exists in paliad (DE-only proceedings). |
|
||||
| R.151 | Final decision (with cost order) → Application for cost decision | 1 month | `inf.cost_app` (RoP.151) | present-correct | ★★★ | |
|
||||
| R.157 | Cost decision delivered | 0 anchor | `cost.decision` (UPC_COST_APPEAL) | present-correct | ★★ | |
|
||||
| R.221.1 | Cost decision served → Application for leave-to-appeal | 15 days | `cost.leave_app` (RoP.221.1) | present-correct | ★★ | Migration 052 §3 wired the leaf cascade. |
|
||||
| R.155 | Cost-decision app served → Defence + Reply chain | 1 month / 14 days | partial | partial | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
|
||||
|
||||
**Section E net:** Cost happy-path covered. **1 missing** (R.118.4), **1 partial** (R.155 cost-decision opposition chain).
|
||||
|
||||
---
|
||||
|
||||
### Section F — Appeals (R.220–R.246)
|
||||
|
||||
The single biggest section. paliad models this across **three proceeding types**: UPC_APP (the main 2mo/4mo appeal), UPC_APP_ORDERS (the 15d orders/with-leave track), UPC_COST_APPEAL (the 15d cost-decision-leave track).
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.220.1.a | Final decision served (decision on merits) | — (anchor) | n/a (anchor on `app.notice`) | n/a | ★★★ | Anchor row, not a deadline. |
|
||||
| R.220.1.b | Final decision served (review of CMO etc.) | — (anchor) | (same) | n/a | ★★ | |
|
||||
| R.220.1.c | Order referred to in R.220.1.c (case-mgmt) | — (anchor) | `app_ord.order` | present-correct | ★★ | |
|
||||
| R.220.2 | Order with leave to appeal granted → Statement of Appeal | 15 days | `app_ord.with_leave` (RoP.220.2) | present-correct | ★★ | Migration 052 §4 fixed the leaf wiring. |
|
||||
| R.220.3 | Order, leave-to-appeal refused → Discretionary review request | 15 days | `app_ord.discretion` (RoP.220.3) | present-correct | ★★ | |
|
||||
| R.221.1 | Cost decision → Leave-to-appeal | 15 days | `cost.leave_app` (RoP.221.1) | present-correct | ★★ | |
|
||||
| R.224.1.a | Final decision served → Statement of Appeal (main track) | 2 months | `app.notice` (RoP.220.1) | **present-wrong (rule_code)** | ★★★ | Rule_code is `RoP.220.1` but the actual citation is **R.224.1.a**. R.220.1 is the trigger-classifier rule, not the duration rule. **Cosmetic but technically wrong code.** |
|
||||
| R.224.1.b | Order in R.220.1.c served → Statement of Appeal (orders track) | 15 days | `app_ord.with_leave` (RoP.220.2) | partial | ★★ | Fires from R.220.2 (with leave) but no separate row for R.224.1.b standalone (orders without leave-grant requirement, e.g. R.220.1.c orders). Same 15 days, but the citation is different. |
|
||||
| R.224.2.a | Decision served → Statement of Grounds (main track) | **4 months** | `app.grounds` (4mo, RoP.220.1) | present-correct (with code drift) | ★★★ | Duration corrected from 2mo to 4mo since the 2026-04-30 audit (§4.4). Rule_code still says `RoP.220.1` — should be `RoP.224.2.a`. |
|
||||
| R.224.2.b | Order in R.220.1.c served → Statement of Grounds (orders track) | 15 days | **missing** | missing | ★★ | UPC_APP_ORDERS has the appeal-itself row but **no separate Grounds row**. R.224.2.b explicitly creates a 15-day grounds period for the orders track. |
|
||||
| R.229.2 | Notification of appeal-deficiency → Correction | 14 days | **missing** | missing | ★ | Registry-correction family. |
|
||||
| R.234.1 | Statement of Appeal received → Court rejects as inadmissible | 1 month | n/a (court action, no party deadline) | n/a | ★ | Court window, not party deadline. |
|
||||
| R.235.1 | Statement of Appeal served → Response (orders track) | 15 days | `app_ord.cross_reply` partially overlaps; standalone response missing | partial | ★★ | UPC_APP_ORDERS has cross + cross_reply but no response-to-the-appeal row. R.235.1 specifically covers the response-to-appeal in the orders/with-leave track. |
|
||||
| R.235.2 | Statement of Appeal served → Response (main track) | 3 months | `app.response` (3mo, no rule_code) | present-wrong (rule_code) | ★★★ | Duration is correct (3 months). Rule_code is NULL — should be `RoP.235.2`. |
|
||||
| R.237 | Response to Appeal served → Cross-Appeal | 3mo (main) / 15d (orders) | `app.cross_a` (3mo, RoP.237) + `app_ord.cross` (15d, RoP.237) | present-correct | ★★ | |
|
||||
| R.238.1 | Cross-Appeal served → Reply (main track) | 2 months | `app.cross_a_reply` (RoP.238.1) | present-correct | ★★ | |
|
||||
| R.238.2 | Cross-Appeal served → Reply (orders track) | 15 days | `app_ord.cross_reply` (RoP.238.2) | present-correct | ★★ | |
|
||||
| R.245.1 | Final decision served → Application for rehearing (main 2mo) | 2 months | **missing** | missing | ★ | |
|
||||
| R.245.2.a | Discovery of fundamental defect → Application for rehearing | 2 months | **missing** | missing | ★ | "Whichever is later" of decision-service vs defect-discovery (cf. trigger event 98). Outer cap 12mo from final decision. |
|
||||
| R.245.2.b | Discovery of criminal offence → Application for rehearing | 2 months | **missing** | missing | ★ | Trigger event 88. Same outer cap. |
|
||||
| R.245.2 cap | Outer cap | 12 months from final decision | **missing** | missing | ★ | Outer-bound logic, not a calendar deadline; needs a "max-of-anchors" capability. |
|
||||
|
||||
**Section F net:** Main happy-path covered. **3 present-wrong (rule_code drift)** on R.224.1.a, R.224.2.a, R.235.2 — the durations are right, the citation strings are wrong/missing. **1 missing** R.224.2.b grounds-on-orders 15d (genuine functional gap). **1 partial** R.235.1 response-on-orders-track. **3 missing** rehearing family (R.245). **1 missing** R.229.2 registry-correction.
|
||||
|
||||
---
|
||||
|
||||
### Section G — Re-establishment, case-management, miscellaneous
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.262.2 | Receipt of opposing party's application for confidentiality | 14 days | **missing** | missing | ★★ | Trigger event 25 (`application_to_request_confidentiality_from_the_public`) exists; no rule. Common in HLC infringement work where competitor secrets are filed. |
|
||||
| R.262A | Confidentiality club application | judge-set | n/a | n/a | ★ | No fixed deadline. |
|
||||
| R.295 | Stay of proceedings | n/a (judicial discretion) | n/a | n/a | ★★ | No deadline. |
|
||||
| R.320 | Wegfall des Hindernisses → Wiedereinsetzung | 2 months (cap 12mo from missed deadline) | concept `wiedereinsetzung` + trigger event 207 + leaf `frist-verpasst.upc` | present-correct (cascade-only) | ★★ | Migration 063 added the cascade path. **No `paliad.deadline_rules` row** — Wiedereinsetzung has no proceeding-tree rule because it bridges proceedings. The 2mo / 12mo logic only lives in description text. **If we want to compute the deadline,** a rule row is needed; today the user gets a concept-card but not a calendar entry. |
|
||||
| R.321.3 | Filing → Referral to central division (preliminary objection sub-case) | 10 days | **missing** | missing | ★ | |
|
||||
| R.331 | Court summons to oral hearing | judge-set | `cms-eingang.gericht.ladung` cascade leaf (no rule) | partial | ★★ | Cascade leaf exists but no fixed period; this is correct since R.331 is judge-set. Mentioned for completeness. |
|
||||
| R.333.2 | Case-management order served → Application for review | 15 days | **missing** | missing | ★★ | Trigger event 16 exists; no rule. Common in busy LDs (review-of-CMO requests are routine). |
|
||||
| R.353 | Decision/order delivered → Application for rectification | 1 month | **missing** | missing | ★ | Trigger event 41 exists; no rule. |
|
||||
|
||||
**Section G net:** R.320 covered cascade-only (computational gap). **5 missing** (R.262.2 confidentiality, R.321.3 referral, R.333.2 review-of-CMO, R.353 rectification, R.320 calendar arithmetic).
|
||||
|
||||
---
|
||||
|
||||
### Section H — Oral-hearing prep + translations (R.109)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.109.1 | Oral hearing date → Request for simultaneous translation | **1 month before** | **missing** | missing | ★★ | The whole "before"-mode family. paliad's `paliad.deadline_rules` has a `timing` column (values `before`/`after`) and `internal/services/deadline_calculator.go` reads it, but **no rule today populates `timing='before'`** — verified via SQL `SELECT DISTINCT timing FROM paliad.deadline_rules WHERE is_active = true` returning `{after}` only. |
|
||||
| R.109.4 | Oral hearing date → Notification of interpreter cost intent | **2 weeks before** | **missing** | missing | ★★ | |
|
||||
| R.109.5 | Oral hearing date → Lodging of translations | 2 weeks **after** summons | **missing** | missing | ★★ | Trigger event 113 (`order_of_the_judge_rapporteur_to_lodge_translations`) exists; no rule. |
|
||||
| R.116 | Oral hearing → Final written submissions (EPO-style cap) | 1 month before (typically) | n/a (UPC has no formal R.116-equivalent — EPC-only) | n/a | ★★ | Concept `r116-final-submissions` is mapped to EPA_OPP / EPA_APP only — correct. |
|
||||
|
||||
**Section H net:** **All 3 R.109 rules missing.** This was flagged in 2026-04-30 §3.6 as a tier-2 port; no migration since. The schema and Go code already support `before`-mode, just no data.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gap list
|
||||
|
||||
Ordered by frequency × user-impact. Each entry is one sentence, sufficient for a coder to spec a migration row.
|
||||
|
||||
### Critical — **★★★** ("real duration bugs verified against RoP text — fix before any further migration work")
|
||||
|
||||
1. **`UPC_REV.rev.defence` duration is 3 months — RoP §49.1 says 2 months.** Single-row UPDATE: `duration_value=2`. Also fix rule_code `RoP.49.1` → `RoP.049.1`. Verified via `data.laws_contents` for `UPCRoP.049.1` (youpc law database).
|
||||
2. **`UPC_REV.rev.rejoin` duration is 2 months — RoP §52 says 1 month.** Single-row UPDATE: `duration_value=1`, set `rule_code='RoP.052'`. Verified via `data.laws_contents` for `UPCRoP.052.p1`.
|
||||
3. **`UPC_REV.rev.reply` rule_code is NULL.** Set `rule_code='RoP.051'`. Duration (2mo) is correct.
|
||||
4. **`UPC_APP.app.notice` / `app.grounds` / `app.response` rule_code drift.** `app.notice` cites `RoP.220.1` (trigger-classifier); should be `RoP.224.1.a` (duration rule). `app.grounds` same drift (→ `RoP.224.2.a`). `app.response` NULL (→ `RoP.235.2`). Cosmetic-but-wrong; durations all correct.
|
||||
|
||||
### High-priority — **★★** ("every case will hit this at some stage")
|
||||
|
||||
5. **R.19 Preliminary Objection (1 month) missing in UPC_INF.** Defendant's first move when challenging jurisdiction/competence/language. No rule, no cascade leaf, no concept card — just a dangling trigger event 68. Add rule + cascade leaf + concept.
|
||||
6. **R.224.2.b Statement of Grounds on orders track (15 days) missing in UPC_APP_ORDERS.** With-leave appeal has the appeal-itself row but no separate grounds row.
|
||||
7. **R.235.1 Response to Appeal on orders track (15 days) missing in UPC_APP_ORDERS.**
|
||||
8. **R.118.4 Application for orders consequential on validity (2 months) missing.** Common follow-on after central-division revocation decision.
|
||||
9. **R.262.2 Confidentiality response (14 days) missing.** Daily occurrence in HLC infringement work.
|
||||
10. **R.333.2 Review of CMO (15 days) missing.** Routine in busy local divisions.
|
||||
11. **R.207.6.a Notification of PI deficiency → Correction (14 days) missing.** Registry-correction family.
|
||||
12. **R.197.3 Saisie review request (30 days) missing.** Standard saisie practice.
|
||||
13. **R.198 / R.213 Start proceedings on the merits (31d OR 20wd, whichever is longer) — blocked on arithmetic primitive.** Needs `working_days` unit or a `combine='max'` operator. Document blocked-on-tooling in the gap-list; do not migrate until the primitive lands.
|
||||
14. **R.207.9 Renewal of protective letter (6 months) missing.**
|
||||
15. **R.109.1 / R.109.4 / R.109.5 Oral-hearing translation prep (1mo / 2w / 2w; first two are `before`-mode) missing.** First two are the only `before`-mode rules in the whole UPC corpus — schema supports it, no data populates it.
|
||||
16. **R.353 Rectification of decision (1 month) missing.**
|
||||
|
||||
### Medium-priority — **★** ("specialist / fringe but real")
|
||||
|
||||
17. **R.20.2 Reply to Preliminary Objection (14 days) missing.**
|
||||
18. **R.229.2 Notification of appeal-deficiency → Correction (14 days) missing.**
|
||||
19. **R.245.1, R.245.2.a, R.245.2.b Rehearing applications (2mo / outer 12mo cap) missing.** Plus the "max-of-two-anchors" arithmetic for R.245.2.
|
||||
20. **R.321.3 Referral to central division (10 days) missing.**
|
||||
21. **R.144 Damages decision tree-end row missing.** Cosmetic — UPC_DAMAGES tree just stops at rejoinder.
|
||||
22. **R.155 Cost-decision opposition chain (Defence + Reply) missing in UPC_COST_APPEAL.** Tree currently jumps from cost decision to leave-to-appeal without modelling the substantive opposition.
|
||||
23. **R.63 / R.67.1 / R.69.1 / R.69.2 DNI family (4 rules) missing.** No UPC_DNI proceeding type. Fringe in HLC practice.
|
||||
24. **R.320 Wiedereinsetzung calendar arithmetic missing.** Cascade card exists; no rule row that computes the 2mo / 12mo deadline. Needs either a `paliad.deadline_rules` row or a special-case Go helper. Touches the "outer cap" arithmetic gap (same pattern as R.245.2).
|
||||
|
||||
### Tooling gaps (block multiple rules)
|
||||
|
||||
25. **`working_days` duration unit + `combine='max'` operator.** Blocks R.198, R.213, and arguably R.198 cross-cuts saisie.
|
||||
26. **`outer_cap_value` + `outer_cap_unit` columns** (or a separate table). Blocks R.320 (12mo cap), R.245.2 (12mo cap).
|
||||
27. **Multi-anchor "whichever is later" trigger events.** Blocks R.245.2.a/b. Trigger events 88 + 98 already encode the OR semantics in their *names* but no Go-side helper picks the later of two user-provided dates.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-cutting observations
|
||||
|
||||
### 5.1 Rule-code citation drift is widespread
|
||||
|
||||
The 2026-04-30 audit (§4.3) noted the format drift (`RoP 23` vs `RoP.023`). That part is now resolved (no `RoP 23` rows exist — all migrated to `RoP.023` style). But a second-order drift remains: **the rule_code field cites the wrong rule** in several places (R.220.1 used for R.224.1.a / R.224.2.a, NULLs on REV reply/rejoinder and `app.response`).
|
||||
|
||||
Recommendation: an audit pass over `paliad.deadline_rules` to align `rule_code` to the *duration-creating* rule, not the *trigger-classifier* rule. Roughly 5-7 rows to update.
|
||||
|
||||
### 5.2 The cascade and the rule-tree drift independently
|
||||
|
||||
paliad has two surfaces:
|
||||
- **Pathway A** (proceeding tree, `paliad.deadline_rules`): "I'm running an UPC infringement case, what's the timeline?"
|
||||
- **Pathway B** (cascade, `paliad.event_categories` + `paliad.event_category_concepts` + `paliad.deadline_concepts`): "the CMS just landed X, what reacts?"
|
||||
|
||||
Both surfaces *should* cover the same RoP universe; in practice they don't. R.320 Wiedereinsetzung is in Pathway B (after migration 063) but not in Pathway A. R.262.2 confidentiality is in neither.
|
||||
|
||||
A single matrix `(RoP rule × Pathway A coverage × Pathway B coverage)` would help future audits. Out of scope here, but worth adding to the rule-library doc once the gaps below are filled.
|
||||
|
||||
### 5.3 Trigger-event corpus is much richer than the rule corpus
|
||||
|
||||
`paliad.trigger_events` has ~90 active rows; `paliad.deadline_rules` references only ~50 distinct UPC scenarios. Many trigger events have no attached rule (R.197.3 review, R.207.9 renewal, R.262.2 confidentiality, R.353 rectification, R.207.6.a deficiency-correction…). The corpus was clearly imported from youpc with the events but without the rules.
|
||||
|
||||
This is the single biggest "missing data" pattern: triggers without rules.
|
||||
|
||||
### 5.4 `before`-mode rules — schema supports, no data populates
|
||||
|
||||
`paliad.deadline_rules.timing` accepts `'before'`/`'after'`. SQL: `SELECT DISTINCT timing FROM paliad.deadline_rules WHERE is_active = true` returns `{after}`. Three R.109 rules need `before`. That's the *only* user need for `before` mode in the entire UPC corpus.
|
||||
|
||||
Verify the date-arithmetic does **subtract** not push-forward — `internal/services/deadline_calculator.go:addDuration` should already handle negative values, but any rule that lands on a non-working day should snap **backward** to the previous working day for `before`-mode (the deadline is "by 1 month before hearing", so a Sunday must move to Friday, not the next Monday — which would be the hearing day or after). 2026-04-30 §5.4 flagged this; verify before adding R.109 rules.
|
||||
|
||||
### 5.4b R.220.3 anchoring nuance (Pathway A vs B drift)
|
||||
|
||||
`UPC_APP_ORDERS.app_ord.discretion` is 15 days, parented to `app_ord.order` (the original CFI order). RoP §220.3 reads: *"within 15 calendar days from the end of [the 15-day refusal] period"*. So the "15 days" duration is anchored on **the day leave-to-appeal was refused (or the day-15 cutoff if no refusal yet)**, not on the order date. The cascade shape (Pathway B) handles this correctly via trigger event 99 (`leave_to_appeal_refused_within_15_days_of_the_order`); the user picks the actual refusal date and the 15d clock runs from there. The proceeding-tree shape (Pathway A) hangs the deadline directly off the order — a user who enters the order date in Pathway A will compute the wrong deadline (15d too early, since the worst-case real deadline is 30d after the order).
|
||||
|
||||
**Recommendation:** either rename `app_ord.discretion`'s anchor to `app_ord.refusal` and add a `app_ord.refusal` court-set node (duration=0, parent=order) for the trigger date, or document in the Fristenrechner UI that the user must enter the refusal date, not the order date. **Not a duration bug — an anchoring/UI bug.** Low-impact (≤30d off in the worst case, only matters at the edge), but worth fixing.
|
||||
|
||||
### 5.5 "Whichever is longer / later" arithmetic
|
||||
|
||||
Three rules need it: R.198, R.213 (max of calendar-days vs working-days), R.245.2.a/b (max of decision-service date vs defect-discovery date). The R.198/R.213 case needs working-days arithmetic (a function of holiday data, which paliad has). The R.245.2 case needs a two-input UI (the user supplies both dates).
|
||||
|
||||
Both can be deferred until a real R.198 or R.245 case lands at HLC. Listing in the gap-list with a `[blocked on tooling]` tag is the right move; no migrations should be drafted until the primitive exists.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended sequencing for a follow-up coder
|
||||
|
||||
(Not a request for migration here — orientation for whoever picks up the gap-fill task.)
|
||||
|
||||
**Wave 0 — DURATION BUGS (must ship first; 2 UPDATE rows + 4 rule_code fixes):**
|
||||
- Fix `rev.defence`: `duration_value` 3 → 2, `rule_code` `RoP.49.1` → `RoP.049.1` (gap 1).
|
||||
- Fix `rev.rejoin`: `duration_value` 2 → 1, set `rule_code='RoP.052'` (gap 2).
|
||||
- Fix `rev.reply`: set `rule_code='RoP.051'` (gap 3).
|
||||
- Fix `app.notice` / `app.grounds` / `app.response` rule_code drift (gap 4).
|
||||
- **Why first:** existing UPC_REV deadlines computed via paliad today are wrong by a month for both Defence and Rejoinder. Any user who set up a Nichtigkeitsverfahren in the last 4 months has miscalibrated reminders. Fix this before any other work.
|
||||
|
||||
**Wave 1 — new rule rows, single migration (~6 rows):**
|
||||
- Add R.19 Preliminary Objection (gap 5).
|
||||
- Add R.262.2 confidentiality (gap 9).
|
||||
- Add R.333.2 review-of-CMO (gap 10).
|
||||
- Add R.224.2.b Grounds-on-orders (gap 6) + R.235.1 response-on-orders (gap 7).
|
||||
- Add R.353 rectification (gap 16).
|
||||
|
||||
**Wave 2 — registry-corrections family (5–6 rows, all 14d):**
|
||||
- R.207.6.a (gap 11), R.229.2 (gap 18), and the rest of the "Mängelbeseitigung" family (R.16.3.a, R.27.2, R.89.2, R.253.2 — already noted in 2026-04-30 §3.1).
|
||||
|
||||
**Wave 3 — saisie + PI gaps (4 rows):**
|
||||
- R.197.3 (gap 12), R.207.9 (gap 14), and document R.198/R.213 as blocked on tooling.
|
||||
|
||||
**Wave 4 — translation prep (3 rows, `before`-mode):**
|
||||
- R.109.1, R.109.4, R.109.5 (gap 15). Test backward-snap to working day before merging.
|
||||
|
||||
**Wave 5 — rare/specialist (5 rows):**
|
||||
- R.20.2 (gap 17), R.144 (gap 21), R.155 (gap 22), R.321.3 (gap 20), R.118.4 (gap 8).
|
||||
|
||||
**Out of scope until tooling lands:**
|
||||
- R.198 / R.213 (working-days arithmetic).
|
||||
- R.245.2 family (multi-anchor arithmetic).
|
||||
- R.320 calendar arithmetic (outer-cap).
|
||||
|
||||
**Out of scope, fringe in HLC practice:**
|
||||
- DNI family (gap 23). Defer until first DNI case at the firm.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for m
|
||||
|
||||
Scope/policy questions, not substantive UPC-rule ambiguities. Listed for the next shift.
|
||||
|
||||
1. **R.245 rehearing — in or out of scope for paliad?** Rare remedy; if a case ever needs it, the lawyer will look it up in the RoP directly. Do we want a tile, or accept that paliad's Fristenrechner is for the 95% common case?
|
||||
2. **R.198 / R.213 working-days arithmetic — implement the primitive, or document as "manual calculation required"?** Real R.198 cases are rare enough that a doc-string + manual override may be cheaper than the schema/code work.
|
||||
3. **R.320 Wiedereinsetzung — should the cascade card produce a calendar entry?** Today migration 063 surfaces the concept (UI tile) but no Frist row gets created. The 2mo / 12mo math is non-trivial because it involves the *missed* deadline as anchor, not a forward-looking event.
|
||||
4. **DNI (R.63–R.70) — is HLC seeing any DNI cases?** Zero published in 2026-Q1; if no internal demand, defer indefinitely.
|
||||
5. **R.262.2 confidentiality 14d — Pathway A or Pathway B only?** It's a reactive deadline (defendant gets opponent's confidentiality request → 14d to respond). Cascade-only seems right; no UPC_CONFIDENTIALITY proceeding type needed.
|
||||
6. **Proceeding-code naming convention — m raised in shift-1 chat.** Today paliad uses underscore-separated codes (`UPC_INF`, `UPC_REV`, `UPC_PI`, `UPC_APP`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_COST_APPEAL`, `UPC_APP_ORDERS`). m suggested a hierarchical dot-notation scheme: `UPC.INF`, `UPC.REV`, `UPC.APM` (= PI), `UPC.A2A` (= application to amend), with instance qualifiers `UPC.INF.CFI` / `UPC.INF.COA` and national-equivalents `DE.INF.1` / `.2` / `.3`. **Trade-off:** consistent grammar across the matrix and trivial parent-child lookup vs. a bulk rename across `paliad.proceeding_types.code`, all `paliad.deadline_rules` rows, all migration files, all front-end strings, and all references in `paliad.event_category_concepts.proceeding_type_code`. **Recommendation:** if we go ahead, do it as a single migration that renames the codes via a mapping table (one column update per affected row) and add a forward-compatibility view aliasing the old codes for any in-flight queries. Don't merge with the duration-bug fixes (Wave 1) — that's two unrelated diff scopes. Worth its own task ticket.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — file references
|
||||
|
||||
**paliad code paths consulted:**
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original UPC_INF / UPC_REV / UPC_PI / UPC_APP / DE_* / EPA_* seed.
|
||||
- `internal/db/migrations/049_event_categories_seed.up.sql` — Pathway B cascade seed.
|
||||
- `internal/db/migrations/050_bilateral_rules_backfill.up.sql` — bilateral / both-party rule seed.
|
||||
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` — prior cascade-side RoP audit fix-pass (R.221 cost-appeal, R.220.3 discretionary review, R.237/238 cross-appeal coverage).
|
||||
- `internal/db/migrations/053_courts_and_countries.up.sql` and onwards — unrelated to deadline_rules.
|
||||
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` — R.320 cascade leaf (no rule row).
|
||||
- `internal/services/deadline_calculator.go` — arithmetic. Reads `timing`, supports `before`/`after`, doesn't yet handle `working_days` or `combine='max'`.
|
||||
- `internal/services/holidays.go` — DB-driven holidays (good shape; would carry working-days arithmetic when added).
|
||||
- `frontend/src/client/fristenrechner.ts` — UI; supports `condition_rule_id` toggle for adaptive rules.
|
||||
|
||||
**RoP citations** are paraphrased from the official UPC Rules of Procedure (consolidated version, in force from 2026-01-01, available at unified-patent-court.org/sites/default/files/upc_documents/rop_consolidated_2026.pdf — verified against the deadline-creating rules I list, without quoting verbatim).
|
||||
|
||||
**Companion audits:**
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad comparison (curie, t-paliad-084).
|
||||
- `docs/audit-polish-2026-04-27.md` and `docs/audit-polish-2-2026-04-29.md` — UI/UX polish audits, not rule-data.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — coverage tally
|
||||
|
||||
Rules audited in scope: **~80 deadline-creating UPC RoP rules** across 8 sections.
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| present-correct | 38 | 47% |
|
||||
| present-wrong (DURATION) | 2 | 3% |
|
||||
| present-wrong (rule_code drift only) | 5 | 6% |
|
||||
| partial | 4 | 5% |
|
||||
| missing | 25 | 31% |
|
||||
| n/a (no deadline) | 8 | 10% |
|
||||
|
||||
**Most-important findings:** the 2 duration bugs (R.49.1 Defence-to-Revocation, R.52 REV Rejoinder) — both ★★★, both impact every active UPC_REV proceeding tracked in paliad today.
|
||||
|
||||
The 25 missing represent the gap list. Of those, **16 are ★★★ / ★★ frequency** (high priority); 9 are ★ specialist.
|
||||
912
docs/design-approval-policy-ui-2026-05-07.md
Normal file
912
docs/design-approval-policy-ui-2026-05-07.md
Normal file
@@ -0,0 +1,912 @@
|
||||
# Approval-policy authoring UI — design
|
||||
|
||||
**Task:** t-paliad-154
|
||||
**Issue:** m/paliad#13
|
||||
**Inventor:** hilbert (2026-05-07)
|
||||
**Branch:** mai/hilbert/inventor-approval-policy
|
||||
**Status:** READY FOR REVIEW
|
||||
|
||||
---
|
||||
|
||||
## §0 — One-paragraph summary
|
||||
|
||||
cronus shipped the t-138 4-eye backend on 2026-05-06: tables, service layer,
|
||||
HTTP API, audit events, the `/inbox` shell. The whole thing has been **dormant
|
||||
in production since** because `paliad.approval_policies` has zero rows, and no
|
||||
UI exists to author policies. m hit this hard 2026-05-07 22:55 — created a
|
||||
deadline expecting a request on `/approvals`, got nothing. This design fills
|
||||
the gap with **two coordinated changes**: (a) a backend extension to support
|
||||
**per-partner-unit defaults** layered with **project-tree inheritance**, both
|
||||
resolved most-restrictive, with an explicit `'none'` sentinel for project-level
|
||||
opt-out; (b) a single new admin page `/admin/approval-policies` with a
|
||||
project-picker → 8-cell matrix and a partner-unit defaults section, plus
|
||||
in-context hints on the deadline/appointment forms when 4-eye applies. v1
|
||||
ships seeded conservative defaults for every existing partner unit so the gate
|
||||
starts working on next deploy without per-project authoring.
|
||||
|
||||
---
|
||||
|
||||
## §1 — What's already built (verified live, 2026-05-07)
|
||||
|
||||
cronus's t-138 implementation is complete and merged. Verified premises:
|
||||
|
||||
- **Schema (migration 054, applied):** `paliad.approval_policies` with
|
||||
`(id, project_id, entity_type, lifecycle_event, required_role, created_at,
|
||||
updated_at, created_by)` + UNIQUE composite on `(project_id, entity_type,
|
||||
lifecycle_event)`. RLS enforces SELECT via `can_see_project(project_id)`,
|
||||
WRITE via `global_role='global_admin'`. Read-only check on the live DB
|
||||
via the migration file at `internal/db/migrations/054_approvals.up.sql:75`.
|
||||
- **Required-role enum (post-059):** `partner | of_counsel | associate |
|
||||
senior_pa | pa`. The `'lead' → 'partner'` rename happened in migration 059
|
||||
(t-148, kepler) — verified at `internal/db/migrations/059_profession_vs_responsibility.up.sql:166-172`.
|
||||
Mirrors `paliad.users.profession` (firm-wide career tier), not
|
||||
`paliad.project_teams.responsibility` (project-level role) — the gate keys
|
||||
on profession because that's how the strict ladder
|
||||
`paliad.approval_role_level()` works.
|
||||
- **HTTP API (admin-gated):** three handlers in
|
||||
`internal/handlers/approvals.go` register at `internal/handlers/handlers.go:421-426`:
|
||||
- `GET /api/projects/{id}/approval-policies` → list
|
||||
- `PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → upsert
|
||||
- `DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → clear
|
||||
|
||||
All three wrapped with `auth.RequireAdminFunc(users, ...)`.
|
||||
- **`LookupPolicy`** (`internal/services/approval_service.go:69-83`) does
|
||||
**not walk the project tree** today. It SELECTs the exact
|
||||
`(project_id, entity_type, lifecycle_event)` tuple and returns the row or
|
||||
nil. Tree inheritance is brand-new in this design.
|
||||
- **Audit:** approval-request submission and decisions emit
|
||||
`paliad.project_events` rows; **policy CRUD does not**. Verified at
|
||||
`internal/services/approval_service.go:255` (request emits) — no
|
||||
`insertProjectEvent` call inside `UpsertPolicy`/`DeletePolicy` at lines
|
||||
913-948.
|
||||
- **Partner-unit substrate (t-139, migration 055, applied):**
|
||||
- `paliad.partner_units (id, name, lead_user_id, office, ...)` — verified
|
||||
at `internal/services/partner_unit_service.go:29`.
|
||||
- `paliad.project_partner_units (project_id, partner_unit_id,
|
||||
derive_grants_authority, derive_unit_roles)` — verified at
|
||||
`internal/db/migrations/055_hierarchy_aggregation.up.sql:47`.
|
||||
- **Admin index pattern:** `/admin` is a card-grid of single-purpose admin
|
||||
sub-pages — team, partner-units, audit-log, email-templates, event-types,
|
||||
broadcasts. Verified at `frontend/src/admin.tsx:60-91`. New approval-policy
|
||||
card slots into the same grid.
|
||||
- **Migration tracker:** last applied is **061**
|
||||
(`paliad.user_card_layouts`). Next is **062** — this design's migration.
|
||||
|
||||
---
|
||||
|
||||
## §2 — m's locked decisions (2026-05-07 23:00)
|
||||
|
||||
12 questions surfaced via AskUserQuestion (per dogma, not as a markdown
|
||||
list). Locked verbatim — quoted as-asked + answer:
|
||||
|
||||
### Q1 — Surface placement
|
||||
|
||||
> Where should approval policies be authored? The backend admin-gates the
|
||||
> CRUD endpoints, so anywhere we surface authoring is admin-only by
|
||||
> definition.
|
||||
|
||||
**Locked: Admin page only.** New `/admin/approval-policies` card on the
|
||||
admin index. Single page with two sections: (a) Partner-unit defaults,
|
||||
(b) Project picker → 8-cell matrix. Per-project tab is **out**. Project
|
||||
visibility into effective rules happens at form-time (Q12 below), not as a
|
||||
permanent tab.
|
||||
|
||||
### Q2 — Default-policy concept
|
||||
|
||||
> With ~30 projects and 8 cells each, authoring is tedious. Should we add
|
||||
> firm-wide defaults that individual projects override?
|
||||
|
||||
**Locked: Per partner-unit defaults.** Schema gets a nullable
|
||||
`partner_unit_id`, project_id becomes nullable, XOR check enforces a row
|
||||
applies to one or the other. Reuses the t-139 partner-unit infra. No
|
||||
firm-wide defaults — one less concept.
|
||||
|
||||
### Q3 — Multi-unit conflict resolution
|
||||
|
||||
> A project attached to multiple partner units with conflicting unit
|
||||
> defaults — e.g. Munich Lit unit defaults to deadline:create=partner,
|
||||
> Düsseldorf to deadline:create=associate. What does the gate require?
|
||||
|
||||
**Locked: Most-restrictive wins.** Take MAX(`approval_role_level`) across
|
||||
all unit defaults for the project. Conservative — 4-eye exists to prevent
|
||||
quiet errors, the higher bar wins.
|
||||
|
||||
### Q4 — Tree inheritance
|
||||
|
||||
> Projects also live in a tree. Should an ancestor project's policy inherit
|
||||
> DOWN the project tree to descendants when they have no own row, or only
|
||||
> via partner-unit defaults?
|
||||
|
||||
**Locked: Both — tree inheritance AND unit defaults.** Three sources
|
||||
contribute to the candidate set: project-specific rows, ancestor rows,
|
||||
unit defaults.
|
||||
|
||||
### Q5 — Cross-source precedence
|
||||
|
||||
> When tree-inheritance and unit-defaults both produce a candidate, which
|
||||
> wins?
|
||||
|
||||
**Locked: Most-restrictive across ALL sources.** Project-specific row
|
||||
overrides outright (any value, including `'none'`). When no project row,
|
||||
take MAX(level) across all ancestor rows + all unit defaults. Symmetric
|
||||
with the multi-unit rule.
|
||||
|
||||
### Q6 — Explicit suppression sentinel
|
||||
|
||||
> A project-specific row always wins. To set 'this project explicitly
|
||||
> bypasses 4-eye on deadline:create' overriding a partner-unit default of
|
||||
> 'partner', we need a sentinel.
|
||||
|
||||
**Locked: `'none'` value in `required_role` enum.** Add `'none'` to the
|
||||
CHECK constraint. Cell renders as "Keine Genehmigung erforderlich". Project
|
||||
row with `required_role='none'` returns nil from `LookupPolicy` —
|
||||
suppresses defaults explicitly. Single column, single concept.
|
||||
|
||||
### Q7 — Soft-disable vs delete
|
||||
|
||||
> Per-policy enable/disable toggle vs delete-only. With audit-log emission
|
||||
> already locked in (Q8), do we still need soft-disable?
|
||||
|
||||
**Locked: Delete-only.** One row = one rule. "This rule used to apply" is
|
||||
answered by the audit log. KISS.
|
||||
|
||||
### Q8 — Audit emission
|
||||
|
||||
> Should policy changes emit project_events?
|
||||
|
||||
**Locked: Only on `/admin/audit-log`, not on per-project `/verlauf`.**
|
||||
New event types `approval_policy_set` and `approval_policy_cleared`
|
||||
emitted via the existing audit-log path (not via the project-events
|
||||
union). Project verlauf stays focused on entity-level history.
|
||||
|
||||
### Q9 — Empty-state on /inbox
|
||||
|
||||
> When admin opens /inbox and pending list is empty AND no policies exist,
|
||||
> show a one-tap nudge?
|
||||
|
||||
**Locked: Yes — admin-only card.** Conditional on `me.global_role ===
|
||||
'global_admin' && pending.length === 0 && !any_policies_exist`. Card links
|
||||
to `/admin/approval-policies`. Solves the discoverability gap m hit.
|
||||
|
||||
### Q10 — Bulk-apply
|
||||
|
||||
> Bulk action on the admin page so an admin can fan a Mandant's matrix
|
||||
> down to its 12 sub-projects without 96 clicks?
|
||||
|
||||
**Locked: Yes — "Auf Unterprojekte anwenden" button per project row.**
|
||||
Click → confirm modal listing affected descendants → applies the source
|
||||
project's full matrix to all descendants. Idempotent.
|
||||
|
||||
### Q11 — Seed defaults on first deploy
|
||||
|
||||
> Should v1 ship seeded defaults, or strictly opt-in?
|
||||
|
||||
**Locked: Seed conservative defaults for every partner_unit.** Migration
|
||||
inserts 8 rows per existing partner_unit:
|
||||
|
||||
| entity | lifecycle | required_role |
|
||||
| :--- | :--- | :--- |
|
||||
| deadline | create | associate |
|
||||
| deadline | update | associate |
|
||||
| deadline | delete | associate |
|
||||
| deadline | complete | none |
|
||||
| appointment | create | associate |
|
||||
| appointment | update | associate |
|
||||
| appointment | delete | associate |
|
||||
| appointment | complete | none |
|
||||
|
||||
Rationale: marking-as-done is low-risk; the planning ops (create/edit/delete
|
||||
the date itself) need 4-eye. `none` on `complete` is an explicit "no gate"
|
||||
sentinel, not a missing row — so MAX-across-sources still works correctly.
|
||||
|
||||
### Q12 — Mobile shape
|
||||
|
||||
> 8-cell matrix is too wide for narrow viewports.
|
||||
|
||||
**Locked: Two stacked sections — Fristen, Termine, each as 4-row list.**
|
||||
On viewports ≥ 700px: 2-row × 4-col matrix. On viewports < 700px: vertical
|
||||
section per entity_type with full-width dropdown rows.
|
||||
|
||||
### Q13 — Form-time hint visibility
|
||||
|
||||
> Should we surface 4-eye to users authoring deadlines, before they save?
|
||||
|
||||
**Locked: Yes — hint on the deadline-form.** Above the Speichern button on
|
||||
`/projects/{id}/deadlines/new` and `/projects/{id}/appointments/new`,
|
||||
render: "4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
|
||||
Genehmigungsantrag (associate-Level) ausgelöst." Pulled from new
|
||||
`GET /api/projects/{id}/approval-policies/effective` endpoint at form load.
|
||||
|
||||
---
|
||||
|
||||
## §3 — Backend extensions
|
||||
|
||||
### §3.1 — Migration 062
|
||||
|
||||
`internal/db/migrations/062_approval_policy_unit_defaults.up.sql`:
|
||||
|
||||
```sql
|
||||
-- t-paliad-154: approval-policy authoring UI substrate.
|
||||
--
|
||||
-- Extends t-138's paliad.approval_policies with:
|
||||
-- 1. partner_unit_id column for unit-default rows (XOR with project_id)
|
||||
-- 2. 'none' sentinel value for required_role (explicit suppression)
|
||||
-- 3. paliad.approval_policy_effective() resolver — tree + unit + most-restrictive
|
||||
-- 4. Conservative seed defaults for every existing partner_unit
|
||||
|
||||
-- 1. partner_unit_id column + nullable project_id + XOR check.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ALTER COLUMN project_id DROP NOT NULL,
|
||||
ADD COLUMN partner_unit_id uuid
|
||||
REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT approval_policies_scope_xor CHECK (
|
||||
(project_id IS NOT NULL AND partner_unit_id IS NULL) OR
|
||||
(project_id IS NULL AND partner_unit_id IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Replace UNIQUE (project_id, ...) with two partial unique indexes since
|
||||
-- project_id is now nullable.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_project_id_entity_type_lifecycle_event_key;
|
||||
|
||||
CREATE UNIQUE INDEX approval_policies_project_unique
|
||||
ON paliad.approval_policies (project_id, entity_type, lifecycle_event)
|
||||
WHERE project_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX approval_policies_unit_unique
|
||||
ON paliad.approval_policies (partner_unit_id, entity_type, lifecycle_event)
|
||||
WHERE partner_unit_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX approval_policies_unit_idx
|
||||
ON paliad.approval_policies (partner_unit_id);
|
||||
|
||||
-- 2. 'none' sentinel.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_required_role_check
|
||||
CHECK (required_role IN (
|
||||
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none'
|
||||
));
|
||||
|
||||
-- approval_role_level('none') already returns 0 (the ELSE branch). No
|
||||
-- function change needed.
|
||||
|
||||
-- 3. Resolver function.
|
||||
--
|
||||
-- Returns the effective policy for (project, entity_type, lifecycle):
|
||||
-- 1. project-specific row → wins outright (any value including 'none')
|
||||
-- 2. else MAX(approval_role_level) across:
|
||||
-- - all ancestor project rows on the path
|
||||
-- - all unit-default rows for partner units attached to project
|
||||
-- 3. else NULL (no candidates) → no policy applies
|
||||
--
|
||||
-- Returns at most one row. Caller can detect "no policy" via empty result.
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
required_role text,
|
||||
source text, -- 'project' | 'ancestor' | 'unit_default'
|
||||
source_id uuid -- project_id for project/ancestor, partner_unit_id for unit_default
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
-- Step 1: project-specific row.
|
||||
RETURN QUERY
|
||||
SELECT ap.required_role, 'project'::text, ap.project_id
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle;
|
||||
IF FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Step 2: MAX across ancestor + unit_default.
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.required_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.required_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
)
|
||||
SELECT a.required_role, a.src, a.sid
|
||||
FROM (SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows) a
|
||||
ORDER BY a.lvl DESC, a.src ASC -- 'ancestor' < 'unit_default' alphabetically; ancestor wins ties for stable attribution
|
||||
LIMIT 1;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
|
||||
'Effective approval policy resolver (t-paliad-154). '
|
||||
'project-specific row wins outright; else MAX(level) across ancestors '
|
||||
'and unit-defaults attached to project; else no policy.';
|
||||
|
||||
-- 4. Seed conservative defaults for every existing partner_unit.
|
||||
INSERT INTO paliad.approval_policies (
|
||||
project_id, partner_unit_id, entity_type, lifecycle_event, required_role
|
||||
)
|
||||
SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
|
||||
FROM paliad.partner_units pu
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('deadline', 'create', 'associate'),
|
||||
('deadline', 'update', 'associate'),
|
||||
('deadline', 'delete', 'associate'),
|
||||
('deadline', 'complete', 'none'),
|
||||
('appointment', 'create', 'associate'),
|
||||
('appointment', 'update', 'associate'),
|
||||
('appointment', 'delete', 'associate'),
|
||||
('appointment', 'complete', 'none')
|
||||
) AS t(entity_type, lifecycle_event, required_role)
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
`062_approval_policy_unit_defaults.down.sql` reverses each step
|
||||
(deletes seeded rows, drops the function, drops indexes, drops the
|
||||
column + constraint, restores the original UNIQUE + CHECK).
|
||||
|
||||
### §3.2 — Service-layer changes
|
||||
|
||||
`internal/services/approval_service.go` changes (additive — existing
|
||||
callers keep working):
|
||||
|
||||
- **Rewire `LookupPolicy`** to call the resolver. New body:
|
||||
```go
|
||||
func (s *ApprovalService) LookupPolicy(ctx, tx, projectID, entityType, lifecycleEvent) (*models.ApprovalPolicy, error) {
|
||||
var row struct {
|
||||
RequiredRole string `db:"required_role"`
|
||||
Source string `db:"source"`
|
||||
SourceID uuid.UUID `db:"source_id"`
|
||||
}
|
||||
q := `SELECT required_role, source, source_id
|
||||
FROM paliad.approval_policy_effective($1, $2, $3)`
|
||||
err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent)
|
||||
if errors.Is(err, sql.ErrNoRows) || row.RequiredRole == "none" {
|
||||
return nil, nil // no policy applies
|
||||
}
|
||||
if err != nil { return nil, fmt.Errorf("lookup approval policy: %w", err) }
|
||||
// Synthetic ApprovalPolicy — preserves the calling contract.
|
||||
return &models.ApprovalPolicy{
|
||||
ProjectID: projectID,
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycleEvent,
|
||||
RequiredRole: row.RequiredRole,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
The submit/decide chain at lines 142-380 continues to work unchanged.
|
||||
`'none'` returning nil means: project explicitly opted out, no request
|
||||
is created on save.
|
||||
|
||||
- **New `GetEffectivePoliciesMatrix(ctx, projectID)`** returns 8 rows
|
||||
(one per `entity_type × lifecycle_event`), each with attribution. Used
|
||||
by the admin page and the form-hint endpoint.
|
||||
```go
|
||||
type EffectivePolicy struct {
|
||||
EntityType string
|
||||
LifecycleEvent string
|
||||
RequiredRole *string // nil if no policy
|
||||
Source *string // nil if no policy
|
||||
SourceID *uuid.UUID
|
||||
}
|
||||
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx, projectID) ([]EffectivePolicy, error)
|
||||
```
|
||||
Implementation: 8 calls to the resolver in a single round-trip via
|
||||
`unnest()` join, or a small batch loop — both fine for ≤8 cells.
|
||||
|
||||
- **Extend `UpsertPolicy` signature** to accept `partnerUnitID *uuid.UUID`
|
||||
alongside `projectID *uuid.UUID`. Existing callers pass projectID + nil.
|
||||
New callers (unit-default endpoints) pass nil + unit ID.
|
||||
```go
|
||||
func (s *ApprovalService) UpsertPolicy(ctx, callerID,
|
||||
projectID, partnerUnitID *uuid.UUID,
|
||||
entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error)
|
||||
```
|
||||
Same for `DeletePolicy`. Validates exactly one of (projectID, partnerUnitID)
|
||||
is set.
|
||||
|
||||
- **New `ApplyMatrixToDescendants(ctx, callerID, sourceProjectID,
|
||||
targetIDs []uuid.UUID)`**: copies all eight rows of `sourceProjectID`'s
|
||||
effective matrix to each `targetIDs[i]` as project-specific rows. Inside
|
||||
one transaction. Validates `targetIDs` are actual descendants via the
|
||||
ltree path predicate. Returns the count of (project, cell) writes
|
||||
performed. Skips cells where source is `'none'` and target already has
|
||||
no row (idempotent). Emits one audit-log event per write.
|
||||
|
||||
- **Audit emission** in `UpsertPolicy` + `DeletePolicy` + `ApplyMatrixToDescendants`:
|
||||
call existing `AuditService.Record` (the same path `/admin/audit-log`
|
||||
uses). New event type strings: `approval_policy_set`, `approval_policy_cleared`.
|
||||
Metadata: scope (project|partner_unit), scope_id, entity_type, lifecycle,
|
||||
old_required_role (for set), new_required_role (for set). The audit
|
||||
service already handles JSON metadata; no schema change.
|
||||
|
||||
**No project_events emission** (per Q8 lock-in). Project verlauf stays
|
||||
focused on entity-level lifecycle.
|
||||
|
||||
### §3.3 — HTTP handlers
|
||||
|
||||
`internal/handlers/approvals.go` extensions:
|
||||
|
||||
- **Existing routes stay** at `handlers.go:421-426` (gated by
|
||||
`RequireAdminFunc`).
|
||||
|
||||
- **New unit-default routes** (also `RequireAdminFunc`-gated, registered
|
||||
in the same admin block at handlers.go:386-427):
|
||||
- `GET /api/admin/partner-units/{unit_id}/approval-policies` — list
|
||||
all rows for that unit.
|
||||
- `PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — upsert.
|
||||
- `DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — clear.
|
||||
- `GET /api/admin/approval-policies/seeded` — quick existence check
|
||||
used by the `/inbox` admin nudge ("are any policies set firm-wide?").
|
||||
|
||||
- **New endpoint for matrix view** (admin page):
|
||||
- `GET /api/admin/approval-policies/matrix?project_id=...` — returns
|
||||
`[]EffectivePolicy` (8 rows with attribution).
|
||||
|
||||
- **New endpoint for form hint** (gateOnboarded, NOT admin-only — every
|
||||
user authoring a deadline needs to see this):
|
||||
- `GET /api/projects/{id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
|
||||
— returns one `EffectivePolicy` row.
|
||||
|
||||
- **New endpoint for bulk apply**:
|
||||
- `POST /api/admin/approval-policies/apply-to-descendants` — body
|
||||
`{source_project_id: uuid, target_project_ids: [uuid, ...]}`. Validates,
|
||||
applies, returns counts.
|
||||
|
||||
- **New endpoint for project tree** (admin page picker — already exists
|
||||
in part):
|
||||
- `GET /api/admin/projects/tree-flat` — flat array of all projects with
|
||||
`id, name, parent_id, depth, path` for the picker. Reuses
|
||||
`ProjectService.ListAllForAdmin` (already present at
|
||||
`internal/services/project_service.go` — admin-scoped tree).
|
||||
|
||||
- **New page handler**:
|
||||
- `GET /admin/approval-policies` → `dist/admin-approval-policies.html`
|
||||
(server-static shell, hydrated on load).
|
||||
|
||||
---
|
||||
|
||||
## §4 — Frontend
|
||||
|
||||
### §4.1 — Admin page `/admin/approval-policies`
|
||||
|
||||
New files:
|
||||
- `frontend/src/admin-approval-policies.tsx` — page shell. Sections:
|
||||
1. Header: "Genehmigungsrichtlinien" + tool-subtitle.
|
||||
2. **"Partner-Unit-Standards"** — accordion list of partner units
|
||||
(fetched from `/api/partner-units`). Each row expandable into the
|
||||
8-cell matrix (Fristen × 4 lifecycle, Termine × 4 lifecycle), each
|
||||
cell a `<select>` with options `partner | of_counsel | associate |
|
||||
senior_pa | pa | none | ❌ keine Regel` (last = delete the row).
|
||||
3. **"Projekt-spezifisch"** — project picker (search + flat tree dropdown
|
||||
reusing `ProjectIndentRow` component from t-149). Below, the same
|
||||
8-cell matrix for the selected project, each cell showing the
|
||||
**effective** value with a small attribution chip:
|
||||
`Projekt` (own row, dark) / `Geerbt von Mandant Acme Corp` (light,
|
||||
italic) / `Standard von Partner Unit Munich Lit` (light, italic) /
|
||||
`Keine Regel` (faint).
|
||||
4. **"Auf Unterprojekte anwenden"** button per project row, opens
|
||||
confirm modal with descendant list.
|
||||
|
||||
- `frontend/src/client/admin-approval-policies.ts` — orchestration.
|
||||
Fetches partner-units, project tree, matrix on selection. Saves on
|
||||
cell change (`PUT` with required_role; `DELETE` when set to "keine
|
||||
Regel"). Re-fetches matrix after save for fresh effective view.
|
||||
Bulk-apply confirm modal + POST.
|
||||
|
||||
### §4.2 — Admin index card
|
||||
|
||||
`frontend/src/admin.tsx`: add a new card to the available section:
|
||||
|
||||
```tsx
|
||||
<a href="/admin/approval-policies" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
|
||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
```
|
||||
|
||||
`ICON_SHIELD` (new SVG) — small shield icon, matches the visual weight of
|
||||
ICON_USERS / ICON_BUILDING.
|
||||
|
||||
### §4.3 — `/inbox` empty-state nudge
|
||||
|
||||
`frontend/src/inbox.tsx`: extend the `<div className="entity-empty"
|
||||
id="inbox-empty">` block with a hidden admin-only sub-block:
|
||||
|
||||
```tsx
|
||||
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
|
||||
<h3 data-i18n="inbox.empty.admin.title">Noch keine Richtlinien aktiv?</h3>
|
||||
<p data-i18n="inbox.empty.admin.body">Konfiguriere, welche Lifecycle-Events 4-Augen-Prüfung erfordern.</p>
|
||||
<a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin.cta">
|
||||
Genehmigungspflichten konfigurieren
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
`frontend/src/client/inbox.ts`: when rendering empty state, fire
|
||||
`/api/admin/approval-policies/seeded`. If response says `{any: false}` AND
|
||||
user is `global_admin`, reveal the nudge. Otherwise hide.
|
||||
|
||||
### §4.4 — Form-time hint on deadline + appointment new/edit
|
||||
|
||||
`frontend/src/deadlines-new.tsx` + `frontend/src/appointments-new.tsx`
|
||||
(also the edit forms): add a hint container above the form-actions:
|
||||
|
||||
```tsx
|
||||
<div className="approval-hint" id="approval-hint" style="display:none">
|
||||
<span className="approval-hint-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD_SMALL }} />
|
||||
<span id="approval-hint-text" />
|
||||
</div>
|
||||
```
|
||||
|
||||
Client TS: on form load, GET
|
||||
`/api/projects/{project_id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
|
||||
(or `update` for edit). If result is non-null and `required_role !== 'none'`,
|
||||
fill the hint:
|
||||
> 4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
|
||||
> Genehmigungsantrag (associate-Level) ausgelöst. Geerbt von Partner Unit
|
||||
> Munich Lit.
|
||||
|
||||
Same for appointments.
|
||||
|
||||
### §4.5 — Mobile shape
|
||||
|
||||
CSS in `frontend/src/styles/global.css`:
|
||||
|
||||
```css
|
||||
/* Desktop: 2-row × 4-col matrix */
|
||||
.approval-matrix {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.approval-matrix { display: block; }
|
||||
.approval-matrix-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.approval-matrix-section h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.approval-matrix-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--paliad-border-soft);
|
||||
}
|
||||
.approval-matrix-row select { width: 50%; }
|
||||
}
|
||||
```
|
||||
|
||||
The TSX renders BOTH structures (matrix grid + section list); CSS toggles
|
||||
based on viewport. Same pattern as the entity-table → entity-list mobile
|
||||
flip in `frontend/src/client/projects-detail.ts`.
|
||||
|
||||
### §4.6 — i18n keys
|
||||
|
||||
~75 new keys in `frontend/src/client/i18n.ts` (DE primary, EN secondary).
|
||||
Major buckets:
|
||||
|
||||
- `admin.card.approval_policies.title` / `.desc`
|
||||
- `approvals.policy.heading` / `.subtitle` / `.empty`
|
||||
- `approvals.policy.section.units` / `.projects`
|
||||
- `approvals.policy.entity.deadline` / `.appointment`
|
||||
- `approvals.policy.lifecycle.create` / `.update` / `.complete` / `.delete`
|
||||
- `approvals.policy.required.partner` / `.of_counsel` / `.associate` / `.senior_pa` / `.pa` / `.none` / `.no_rule`
|
||||
- `approvals.policy.source.project` / `.ancestor` / `.unit_default`
|
||||
- `approvals.policy.bulk.cta` / `.modal.title` / `.modal.confirm` / `.modal.cancel` / `.modal.target_count` / `.modal.affected_list`
|
||||
- `approvals.policy.unit_picker.placeholder` / `.project_picker.placeholder`
|
||||
- `approvals.policy.cell.save_msg` / `.delete_msg` / `.error_msg`
|
||||
- `inbox.empty.admin.title` / `.body` / `.cta`
|
||||
- `deadlines.form.approval_hint.create` / `.update`
|
||||
- `appointments.form.approval_hint.create` / `.update`
|
||||
- `approvals.policy.audit.set` / `.cleared` (for `/admin/audit-log` rendering)
|
||||
|
||||
---
|
||||
|
||||
## §5 — Resolution semantics (worked examples)
|
||||
|
||||
Helps the implementer + reviewers reason about edge cases.
|
||||
|
||||
### Example A — straight unit default
|
||||
|
||||
**Setup:** Project P attached to one partner unit U. U has unit-default
|
||||
`deadline:create=associate`. P has no own row, no ancestor with a row.
|
||||
|
||||
**Effective for P, deadline:create:**
|
||||
- Step 1: no project row.
|
||||
- Step 2: ancestor_rows = ∅. unit_rows = [{associate, level=3}]. MAX = associate.
|
||||
- Result: `(required_role='associate', source='unit_default', source_id=U.id)`.
|
||||
|
||||
LookupPolicy returns `&ApprovalPolicy{RequiredRole: "associate", ...}`.
|
||||
SubmitCreate creates a pending request needing associate sign-off.
|
||||
|
||||
### Example B — most-restrictive across two unit defaults
|
||||
|
||||
**Setup:** Project P attached to U1 (deadline:create=partner) and U2
|
||||
(deadline:create=associate). No project row, no ancestor row.
|
||||
|
||||
**Effective for P, deadline:create:**
|
||||
- Step 1: no project row.
|
||||
- Step 2: unit_rows = [{partner, lvl=5}, {associate, lvl=3}]. MAX = partner.
|
||||
- Result: `(required_role='partner', source='unit_default', source_id=U1.id)`.
|
||||
|
||||
### Example C — most-restrictive across tree + unit
|
||||
|
||||
**Setup:** Project hierarchy: Mandant M (deadline:create=of_counsel) → Litigation L → Patent P. P attached to unit U (deadline:create=partner).
|
||||
|
||||
**Effective for P, deadline:create:**
|
||||
- Step 1: no row on P.
|
||||
- Step 2: ancestor_rows = [{of_counsel, lvl=4 (from M)}]. unit_rows = [{partner, lvl=5}]. MAX = partner.
|
||||
- Result: `(required_role='partner', source='unit_default', source_id=U.id)`.
|
||||
|
||||
### Example D — explicit suppression at project level
|
||||
|
||||
**Setup:** Same as Example C, but admin sets P's own row to
|
||||
`required_role='none'` (carve-out for this single Patent — e.g. a low-stakes
|
||||
auxiliary case).
|
||||
|
||||
**Effective for P, deadline:create:**
|
||||
- Step 1: project row exists with `required_role='none'`. RETURN.
|
||||
- Result: `(required_role='none', source='project', source_id=P.id)`.
|
||||
|
||||
LookupPolicy returns nil (the `'none'` short-circuit). SubmitCreate skips.
|
||||
|
||||
### Example E — most-restrictive incl. ancestor
|
||||
|
||||
**Setup:** Mandant M (deadline:create=partner). Litigation L below M, no
|
||||
own row, attached to unit U (deadline:create=pa).
|
||||
|
||||
**Effective for L, deadline:create:**
|
||||
- Step 1: no row on L.
|
||||
- Step 2: ancestor_rows = [{partner, lvl=5}]. unit_rows = [{pa, lvl=1}]. MAX = partner.
|
||||
- Result: `(required_role='partner', source='ancestor', source_id=M.id)`.
|
||||
|
||||
The Mandant-level rule cascades down — the typical "set once at the
|
||||
client root" pattern.
|
||||
|
||||
---
|
||||
|
||||
## §6 — Implementation phasing
|
||||
|
||||
Single PR (~3500-4500 LoC). Five commits, ordered for readability:
|
||||
|
||||
1. **Migration 062 + resolver function + seed.** No Go code change.
|
||||
Schema is forward-compatible: existing `LookupPolicy` (still scanning
|
||||
the table directly) keeps working until commit 2 swaps it. Verify
|
||||
migration with TEST_DATABASE_URL + reset.
|
||||
|
||||
2. **ApprovalService rewire.** New `LookupPolicy` body via resolver, new
|
||||
`GetEffectivePoliciesMatrix`, extended `UpsertPolicy`/`DeletePolicy`
|
||||
signatures, new `ApplyMatrixToDescendants`, audit emission. Unit
|
||||
tests (table-driven): resolver fall-through cases A-E above; bulk-apply
|
||||
idempotency; `'none'` short-circuit; XOR check.
|
||||
|
||||
3. **HTTP handlers.** Wire new admin routes + form-hint endpoint +
|
||||
matrix endpoint. Hand-roll `models.ApprovalPolicy` extensions
|
||||
(PartnerUnitID, Source, SourceID nullable fields). Update existing
|
||||
`handleListApprovalPolicies` to return matrix shape (with attribution)
|
||||
instead of raw rows.
|
||||
|
||||
4. **Frontend admin page.** `admin-approval-policies.tsx` + `.ts`. Cells
|
||||
render with attribution chips. Bulk-apply confirm modal. Build wires
|
||||
the new bundle into `frontend/build.ts`. CSS for the matrix grid +
|
||||
mobile sections.
|
||||
|
||||
5. **Frontend touch-ups + i18n.** Admin index card. Inbox empty-state
|
||||
admin nudge. Deadline/appointment form hints (`/api/projects/{id}/approval-policies/effective`
|
||||
call + hint render). ~75 i18n keys DE+EN. CSS finalization.
|
||||
|
||||
Optional split point: 1+2+3 (backend + schema, "policies authoring works
|
||||
via curl") and 4+5 (UI). Recommended single PR — 4+5 are the part that
|
||||
makes the feature reachable to m, and shipping backend-only re-exposes
|
||||
the issue m hit.
|
||||
|
||||
---
|
||||
|
||||
## §7 — Tests
|
||||
|
||||
**Backend (Go, table-driven):**
|
||||
- `approval_service_test.go` extensions for the resolver:
|
||||
- Project row only → returns project row.
|
||||
- Project row 'none' → returns nil from LookupPolicy.
|
||||
- Two unit defaults → most-restrictive.
|
||||
- Ancestor row + unit default → most-restrictive across both.
|
||||
- Project row + ancestor + unit defaults → project row wins.
|
||||
- No candidates → returns nil.
|
||||
- 'none' as unit-default value (low-priority — unusual but allowed) →
|
||||
loses to any non-none.
|
||||
- `ApplyMatrixToDescendants` tests:
|
||||
- Source has 8 cells → target gets 8 cells.
|
||||
- Source has 5 cells (3 cleared) → target gets 5 cells; existing target
|
||||
rows for the other 3 are deleted (idempotent fanout, not append).
|
||||
- Target is not actually a descendant → returns ErrInvalidInput.
|
||||
- Self-target (target == source) → no-op.
|
||||
- `UpsertPolicy` XOR validation: both NULL → ErrInvalidInput; both set →
|
||||
ErrInvalidInput.
|
||||
- Audit emission: each set/clear writes one `paliad.audit_log` row with
|
||||
the right event type + scope.
|
||||
|
||||
**Live-DB integration tests (TEST_DATABASE_URL):**
|
||||
- Migration 062 up + seed populates 8 rows × N partner_units. Down
|
||||
reverses. Idempotent on re-up.
|
||||
- Resolver function returns expected attribution for the 5 worked
|
||||
examples above.
|
||||
|
||||
**Frontend:**
|
||||
- `admin-approval-policies` smoke tests (Playwright): load page, select
|
||||
partner unit, change a cell, verify save → DB. Select project, verify
|
||||
attribution chips. Bulk-apply happy path.
|
||||
- Form-hint on `/projects/{id}/deadlines/new` shows when policy applies,
|
||||
hides when it doesn't.
|
||||
|
||||
---
|
||||
|
||||
## §8 — Trade-offs flagged
|
||||
|
||||
1. **Seed defaults touch live data on first deploy.** Every existing
|
||||
partner_unit gains 8 policy rows. m's locked-in choice (Q11) — but
|
||||
worth flagging that the moment migration 062 runs in production, the
|
||||
4-eye gate becomes active for every project attached to a partner
|
||||
unit. Mitigation: deploy after announcing to the team. Conservative
|
||||
`associate` baseline means most users (associate, of_counsel, partner)
|
||||
can both submit AND approve, so the operational impact is "your save
|
||||
creates a pending request that any teammate can sign off in /inbox"
|
||||
rather than "your save is blocked". The bell-icon + sidebar badge
|
||||
from t-138 surfaces it.
|
||||
|
||||
2. **Seed `'none'` on `complete` is structurally invisible.** A
|
||||
unit-default of `'none'` always loses MAX to any non-none source
|
||||
(level 0 vs ≥1). So the seed `appointment.complete=none` rows are
|
||||
essentially "no rule" — they don't appear in `LookupPolicy` results.
|
||||
We seed them anyway for **UI consistency**: when an admin opens the
|
||||
matrix, they see 8 cells filled with values, not 4 cells filled +
|
||||
4 cells empty. Documenting this as intentional.
|
||||
|
||||
3. **'ancestor' source attribution can be ambiguous when multiple
|
||||
ancestors have rows.** The resolver picks the highest-level row;
|
||||
if Mandant=of_counsel and Litigation=partner, attribution surfaces
|
||||
`source='ancestor', source_id=Litigation`. The Mandant rule is
|
||||
silently overridden. The UI chip says "Geerbt von Litigation X" with
|
||||
no hint that the Mandant also has a rule. Cost: minor — admin can
|
||||
navigate to the Mandant's matrix and see its row directly. Mitigation
|
||||
option (deferred): the matrix-endpoint for the admin page returns
|
||||
the FULL stack of contributing rows per cell, so the chip can say
|
||||
"Strengste von 3 Quellen". Worth doing if v1 attribution feels
|
||||
confusing in practice.
|
||||
|
||||
4. **Audit lives only in `/admin/audit-log`, not in project verlauf.**
|
||||
Per Q8 lock-in. Minor side effect: a non-admin user wondering "why
|
||||
does my deadline now need approval?" can't see the policy-set event
|
||||
on the project's verlauf. They have to check the deadline-form hint
|
||||
(which says "Geerbt von Partner Unit Munich Lit") and ask an admin
|
||||
for the change history. Acceptable trade-off — most users don't need
|
||||
policy change history, only admins who set them.
|
||||
|
||||
5. **Bulk-apply destroys target's existing project-specific rows for the
|
||||
8 cells.** Idempotent fanout means setting source to "matrix M" makes
|
||||
targets match M, including DELETE of any pre-existing target rows
|
||||
that aren't in M. This is by design (otherwise re-applying a partially-
|
||||
reduced source wouldn't actually reduce). Confirm modal lists the
|
||||
affected rows clearly: "12 Projekte, 8 Felder pro Projekt, ggf.
|
||||
bestehende Werte überschrieben". One audit-log row per write so the
|
||||
change is fully traceable.
|
||||
|
||||
6. **Mobile section list duplicates the matrix data structure in the
|
||||
DOM.** TSX renders both the grid table and the stacked sections; CSS
|
||||
toggles based on viewport. Slight DOM bloat (16 cells × 2 = 32 form
|
||||
nodes per partner unit) but matches the entity-table → entity-list
|
||||
pattern already used elsewhere. Alternative (single DOM rendered
|
||||
responsively via flex/grid-flow) is uglier in TSX.
|
||||
|
||||
---
|
||||
|
||||
## §9 — Files the implementer will touch
|
||||
|
||||
**Backend (Go):**
|
||||
- `internal/db/migrations/062_approval_policy_unit_defaults.up.sql` (new)
|
||||
- `internal/db/migrations/062_approval_policy_unit_defaults.down.sql` (new)
|
||||
- `internal/services/approval_service.go` (rewire `LookupPolicy`, add
|
||||
`GetEffectivePoliciesMatrix`, `ApplyMatrixToDescendants`, extend
|
||||
`UpsertPolicy`/`DeletePolicy`)
|
||||
- `internal/services/approval_service_test.go` (new resolver tests, bulk-apply tests, XOR tests)
|
||||
- `internal/models/approval.go` (extend `ApprovalPolicy` with optional
|
||||
`PartnerUnitID`, `Source`, `SourceID`)
|
||||
- `internal/handlers/approvals.go` (new unit-default + matrix + form-hint + bulk-apply handlers)
|
||||
- `internal/handlers/handlers.go` (route registration for the new endpoints + `/admin/approval-policies` page)
|
||||
|
||||
**Frontend (TS/TSX):**
|
||||
- `frontend/src/admin-approval-policies.tsx` (new)
|
||||
- `frontend/src/client/admin-approval-policies.ts` (new)
|
||||
- `frontend/src/admin.tsx` (add card)
|
||||
- `frontend/src/inbox.tsx` (admin-nudge block)
|
||||
- `frontend/src/client/inbox.ts` (gate + reveal nudge)
|
||||
- `frontend/src/deadlines-new.tsx` + `frontend/src/client/deadlines-new.ts` (hint render)
|
||||
- `frontend/src/appointments-new.tsx` + `frontend/src/client/appointments-new.ts` (hint render)
|
||||
- `frontend/src/styles/global.css` (matrix grid + mobile sections + attribution chip)
|
||||
- `frontend/src/client/i18n.ts` (~75 new keys × 2 langs)
|
||||
- `frontend/build.ts` (new bundle entry: admin-approval-policies)
|
||||
|
||||
**Estimate:** ~3500-4500 LoC (matches t-138 + t-144 design phases — small
|
||||
admin page, small migration, mostly mechanical wiring + CSS + i18n).
|
||||
|
||||
---
|
||||
|
||||
## §10 — Recommended implementer
|
||||
|
||||
Pattern-fluent Sonnet — substrate is well-trodden:
|
||||
|
||||
- Admin-page pattern → `frontend/src/admin-partner-units.tsx` is the
|
||||
canonical reference (partner-unit picker → details panel; same shape
|
||||
here with project picker → matrix panel).
|
||||
- Project-detail edit-in-place → `client/projects-detail.ts` for the
|
||||
`<select>`-on-row-click affordance pattern.
|
||||
- ltree path-walk in SQL → `internal/services/visibility.go` and the
|
||||
existing `paliad.can_see_project()` are the reference pattern.
|
||||
- Audit emission → `internal/services/audit_service.go` (already plumbed).
|
||||
- Form-hint above Speichern → similar to the t-148 profession hint
|
||||
on `frontend/src/projects-detail.tsx:130` (`team-profession-hint`).
|
||||
|
||||
**NOT cronus** per memory directive (paliad). **NOT noether** (parked on
|
||||
t-151 and t-144). **NOT godel** (just fired on t-149). **NOT hilbert
|
||||
(me)** — I'm parked after this design; head decides if I take the
|
||||
coder shift on the same worktree (mai/hilbert/inventor-approval-policy)
|
||||
or hands it to a fresh coder.
|
||||
|
||||
---
|
||||
|
||||
## §11 — Out of scope (deferred to follow-ups)
|
||||
|
||||
- **Per-policy time-window** — "this rule applies only Mon–Fri 9–17, after
|
||||
hours skip 4-eye". Some firms do this. Deferred: another column would
|
||||
be cheap, but no signal yet that anyone wants it.
|
||||
- **Per-user exemptions** — "Alice is on PTO, route around her". Same
|
||||
shape as today's `decision_kind='admin_override'` escape hatch — already
|
||||
available via global_admin.
|
||||
- **Multi-step approvals** — "needs partner THEN of_counsel sign-off".
|
||||
cronus's t-138 is single-step by design (Q3 of t-138 locked it). Not
|
||||
in scope here.
|
||||
- **Policy templates / copy-from-other-project** — beyond bulk-apply-to-
|
||||
descendants. If needed, would slot into the admin page as a
|
||||
"Vorlage anwenden" affordance. Not v1.
|
||||
- **Per-event_type policies** — "deadline.create with event_type='Klage'
|
||||
needs partner; everything else of_counsel". The existing schema is
|
||||
per-(entity_type, lifecycle_event); event-type granularity would
|
||||
require an extra column + index. No signal yet.
|
||||
|
||||
---
|
||||
|
||||
**END OF DESIGN.**
|
||||
|
||||
Inventor stays parked. Awaits m's go/no-go on the 12 locked decisions
|
||||
before any coder shift. Hand-off via head once green.
|
||||
828
docs/design-approvals-2026-05-06.md
Normal file
828
docs/design-approvals-2026-05-06.md
Normal file
@@ -0,0 +1,828 @@
|
||||
# Design — Dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-06
|
||||
**Task:** t-paliad-138 (Gitea m/paliad#3)
|
||||
**Branch:** `mai/cronus/inventor-dual-control`
|
||||
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Add a 4-eye principle to `paliad.deadlines` and `paliad.appointments`. Every state-changing action (create / update-of-date-fields / complete / delete) submitted by one team member must be signed off by a qualified second team member from the same project before the change is "approved".
|
||||
|
||||
Six locked design decisions from m (2026-05-06):
|
||||
|
||||
| # | Question | Locked answer |
|
||||
|---|---|---|
|
||||
| Q1 | Where does the qualification level live? | **Reuse `project_teams.role` per-project** (no new firm-wide column). New value `senior_pa` added to the role enum. |
|
||||
| Q1+ | Strict-ladder default? | **Default approval-eligible = {lead, associate}**. Per-project / per-event setting can extend to `senior_pa` or `pa` (so PAs can approve other PAs in some projects). |
|
||||
| Q2 | Hierarchy semantics | **Strict ladder.** Higher level always satisfies lower. |
|
||||
| Q3 | Policy granularity | **Per-(project, entity_type, lifecycle_event)** \— up to 8 settable rows per project. |
|
||||
| Q4 | Edit-trigger fields | **Only date-changing fields.** Deadline: `due_date`, `original_due_date`, `warning_date`. Appointment: `start_at`, `end_at`. All other field changes bypass approval. |
|
||||
| Q5 | Pending-state architecture | **Write-then-approve.** Field changes apply immediately; the entity carries `approval_status='pending'` until an approver flips it. (Delete is the one exception — see §5.4.) |
|
||||
| Q6 | Inbox surface | **Bell icon (sidebar header) + dedicated `/inbox` page** with two tabs: "Zur Genehmigung" / "Meine Anfragen". |
|
||||
| Q7 | Revocation | **Pending-only revoke.** After approval, only path back is a new request. |
|
||||
| Q8 | Single-qualified-approver deadlock | **Refuse + global_admin override.** UI refuses with "Kein qualifizierter Approver verfügbar"; global_admin can manually approve as override (audit-marked). |
|
||||
| Q9 | Audit / chronology | **Both** \— operational `paliad.approval_requests` table + new event types in `paliad.project_events`. Both creator and approver names persist on the entity row. |
|
||||
| Q10 | RLS | **Visible to project team, action gated by service.** Same `can_see_project()` predicate; service layer checks "caller has required role tier AND caller_id != requested_by". |
|
||||
| Q11 | Migration of existing rows | **Mark legacy + skip backfill.** All existing rows get `approval_status='legacy'`. New lifecycle events on legacy rows trigger normal approval flow. |
|
||||
|
||||
Plus m's explicit interjection: **pending state must be visualised everywhere the entity normally surfaces** — list views, agenda, dashboard traffic-light, project detail, CalDAV-synced calendars, and email reminders. Silence on a pending change creates more risk than visible-but-flagged-pending.
|
||||
|
||||
Out of scope for v1: notes, parties, documents, checklists; cross-app generalisation; multi-step n-of-m chains; email/WhatsApp/Telegram approvals (in-app only).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — what's already in the code
|
||||
|
||||
What this design slots into:
|
||||
|
||||
- **Three-axis principle (m, t-paliad-051, sacrosanct).** "Firm roles ≠ project roles ≠ tool roles."
|
||||
- `paliad.users.job_title` — free-text display. Never gates anything.
|
||||
- `paliad.users.global_role` — `standard` | `global_admin`. Tool-admin gate only.
|
||||
- `paliad.project_teams.role` — `lead | associate | pa | of_counsel | local_counsel | expert | observer`. Per-project membership role.
|
||||
- **Visibility:** `paliad.can_see_project()` SQL function (migration 023) + Go mirror `services.visibilityPredicate()` — global_admin OR any team membership on the project's path. Service-role connection bypasses RLS, so the Go mirror is load-bearing; RLS is defense-in-depth.
|
||||
- **Audit:** `paliad.project_events` (created in migration 005 as `akten_events`, renamed in 018). Every mutation on every project-scoped entity emits one row via `services.insertProjectEventWithMeta()` inside the same tx. Carries `event_type`, `title`, `description`, `metadata jsonb`, `created_by`, `event_date`. Read by `services.AuditService` and by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102).
|
||||
- **Entity tables:** `paliad.deadlines` and `paliad.appointments`. Both already carry `created_by uuid REFERENCES auth.users(id)`. Deadlines have `status text CHECK IN ('pending','completed','cancelled','waived')`. Appointments have no status column.
|
||||
- **Service layer:** `DeadlineService.{Create,Update,Complete,Reopen,Delete}`, `AppointmentService.{Create,Update,Delete}`. Each goes through `ProjectService.GetByID(ctx, userID, projectID)` for visibility before mutating. Each emits its `*_created` / `*_updated` / `*_completed` / `*_deleted` event in the same tx.
|
||||
- **Existing patterns this design reuses:**
|
||||
- `paliad.partner_unit_events` audit table (migration 027) — proves the side-table-with-RLS shape works alongside `project_events`.
|
||||
- `paliad.event_types` + `paliad.deadline_event_types` (migration 030) — the picker / multi-select / chip UI pattern is reusable for the "required role" select on the policy authoring page.
|
||||
- `services.visibilityPredicate(alias)` — same shape for the new `approvalEligibleInProject(userID, projectID, requiredRole)` helper.
|
||||
|
||||
This design adds **no new auth/permission axis**. It reuses `project_teams.role` for the qualification gate, per m's Q1 decision. The 3-axis principle holds because the gate uses the existing project axis, not a new firm-wide one.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approval ladder
|
||||
|
||||
### 2.1 Strict ladder over `project_teams.role`
|
||||
|
||||
```
|
||||
level | role | approval-eligible by default?
|
||||
------+------------------+-------------------------------
|
||||
5 | lead | yes — partner-tier on this project
|
||||
4 | of_counsel | yes — senior tier
|
||||
3 | associate | yes ← default required level
|
||||
2 | senior_pa (new) | only if project policy lowers required to 'senior_pa' or below
|
||||
1 | pa | only if project policy lowers required to 'pa'
|
||||
0 | local_counsel | ineligible — external attorney, not in approval scope
|
||||
0 | expert | ineligible — technical witness role
|
||||
0 | observer | ineligible — read-only audit role
|
||||
```
|
||||
|
||||
`senior_pa` is added to the `paliad.project_teams.role` CHECK constraint via migration 054 (see §6.1). It currently has no value in the enum.
|
||||
|
||||
**Strict-ladder rule:** a user with project_teams.role `R` can approve any request whose `required_role` is at level ≤ `level(R)`. So:
|
||||
|
||||
- Default `required_role = 'associate'` (level 3) → eligible approvers: lead, of_counsel, associate.
|
||||
- Override to `required_role = 'senior_pa'` (level 2) → eligible: lead, of_counsel, associate, senior_pa.
|
||||
- Override to `required_role = 'pa'` (level 1) → eligible: lead, of_counsel, associate, senior_pa, pa. This is the "PAs approve other PAs" mode m called for.
|
||||
- Override to `required_role = 'lead'` → only the project lead can approve.
|
||||
|
||||
**Hard rules:**
|
||||
|
||||
1. **Self-approval is hard-blocked.** `caller_id = requested_by` always returns 403, regardless of role. This is enforced at the Go service layer (the only place that mutates approval state) and by a CHECK constraint on the row at decision time (`approved_by != requested_by`).
|
||||
2. **Eligible level 0 = ineligible.** A user with role=local_counsel/expert/observer **cannot** approve any request, even if they're the only team member. They appear in the inbox with "Sie sind nicht qualifiziert" instead of the approve button.
|
||||
3. **`global_admin` is an explicit override path** (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked as `decision_kind='admin_override'`.
|
||||
|
||||
### 2.2 Why not introduce a firm-wide qualification column?
|
||||
|
||||
The issue listed candidates `partner / senior_attorney / attorney / senior_pa / pa / paralegal` and asked whether roles should be global, per-team, or per-project. m chose **per-project** (Q1 = "Reuse project_teams.role"). Rationale (mine, before m chose; reproduced for the record):
|
||||
|
||||
A firm-wide rank column would have:
|
||||
- Cleanly separated from `job_title` (display) and `global_role` (tool admin).
|
||||
- Made authoring rules trivial — one column on `users`, one int compare.
|
||||
- Worked even before a project's team was fully populated.
|
||||
|
||||
But it would have:
|
||||
- Added a 4th identity-axis to maintain (firm rank), violating the spirit of the three-axis principle even if the letter holds.
|
||||
- Forced a firm-wide ladder onto a project context where seniority is already encoded — `lead` on a project IS the partner-tier on that project.
|
||||
- Introduced the question "what if firm rank disagrees with project role" (a senior partner staffed as `observer` on a small case) without a clean answer.
|
||||
|
||||
m's per-project choice is consistent with how the rest of paliad treats authority: the `lead` role on `project_teams` is the source of truth for "who is the partner running this case", and approvals naturally cluster around that.
|
||||
|
||||
### 2.3 What about local_counsel / expert / observer?
|
||||
|
||||
Default: ineligible to approve. Rationale:
|
||||
|
||||
- **local_counsel** is an external attorney (Mitanwalt) — not always a firm employee, often outside the firm's approval chain.
|
||||
- **expert** is a technical / scientific consultant role — not legally qualified to sign off on procedural deadlines.
|
||||
- **observer** is explicitly a read-only role.
|
||||
|
||||
**Escape hatch:** if a project genuinely wants its local_counsel to approve, the team admin can re-add them with `role='associate'` (or whatever tier is intended). The role on `project_teams` is a per-project assignment; the same human can be `local_counsel` on Project A and `associate` on Project B if that's the correct authority on each.
|
||||
|
||||
**Out of scope (follow-up if needed):** a per-project list of "additional approval-eligible roles" that promotes local_counsel/expert into the eligible set without changing their primary project role. Probably not worth the complexity for the few cases where it'd matter.
|
||||
|
||||
---
|
||||
|
||||
## 3. Policy grammar — `paliad.approval_policies`
|
||||
|
||||
### 3.1 Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.approval_policies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
|
||||
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
UNIQUE (project_id, entity_type, lifecycle_event)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_policies_project_idx ON paliad.approval_policies (project_id);
|
||||
```
|
||||
|
||||
Design choices:
|
||||
|
||||
- **Up to 8 rows per project.** `(deadline,create), (deadline,update), (deadline,complete), (deadline,delete), (appointment,create), (appointment,update), (appointment,complete), (appointment,delete)`. UNIQUE composite key enforces this.
|
||||
- **No row = no approval needed for that event.** A project with zero policy rows is in the same operational state as today — no 4-eye anywhere.
|
||||
- **`required_role` is a single value**, not a min-level int. Stored as text matching `project_teams.role` values; the strict ladder is applied in code (see `levelOf(role)` in §3.4). Storing the enum value (rather than an int level) keeps the row readable in `psql` and survives any future ladder reordering.
|
||||
- **Appointment lifecycle includes `complete`**. Today appointments don't have a `completed_at` column or status field. We add one via migration 054 to give `appointment:complete` somewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can drop `complete` for `appointment` and the migration becomes lighter.)
|
||||
|
||||
### 3.2 Inheritance
|
||||
|
||||
**No automatic inheritance from parent project.** A child project (e.g. a single Verfahren under a Litigation parent) does NOT auto-inherit its parent's policy. Reasons:
|
||||
|
||||
- Inheriting would silently change behaviour when projects are reparented (t-paliad-018 already has reparent semantics).
|
||||
- Policy authoring per-Verfahren is the right default — different stages of a litigation may legitimately need different scrutiny.
|
||||
- The path-walking logic for "find the closest ancestor with policy" adds complexity for marginal value.
|
||||
|
||||
**UI affordance:** project detail → Settings → Approvals tab → "Aus Eltern-Projekt übernehmen" button copies the parent's 8 rows into this project. One-shot copy, no live link. Documented as a productivity shortcut.
|
||||
|
||||
### 3.3 Authoring permission
|
||||
|
||||
**v1: global_admin only.** Consistent with the existing /admin/team and /admin/partner-units pattern. Per-project leads cannot edit policy on their own projects in v1.
|
||||
|
||||
**Reasoning:** approval policy is firm-governance-grade — getting it wrong loosens compliance. Concentrating in global_admin is safer for v1. Lifting to "project lead can edit policy on their project" is a one-line gate change.
|
||||
|
||||
**Out of scope follow-up:** lead-can-edit-own-project-policy. File as t-paliad-139 if needed once the v1 ships.
|
||||
|
||||
### 3.4 Service-layer helpers
|
||||
|
||||
```go
|
||||
// internal/services/approval_levels.go
|
||||
|
||||
// levelOf maps a project_teams.role value to the strict-ladder level used
|
||||
// for approval gating. Returns 0 (ineligible) for roles outside the
|
||||
// approval ladder (local_counsel, expert, observer).
|
||||
func levelOf(role string) int {
|
||||
switch role {
|
||||
case "lead": return 5
|
||||
case "of_counsel": return 4
|
||||
case "associate": return 3
|
||||
case "senior_pa": return 2
|
||||
case "pa": return 1
|
||||
default: return 0 // local_counsel, expert, observer, anything new
|
||||
}
|
||||
}
|
||||
|
||||
// canApprove returns true iff:
|
||||
// - caller is not the requester (self-approval blocked)
|
||||
// - caller's project_teams.role on this project has level >= required level
|
||||
// OR caller is global_admin (which is always allowed and audit-marked separately).
|
||||
func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole string, requesterID uuid.UUID) (bool, kind string, err error) {
|
||||
if callerID == requesterID {
|
||||
return false, "", ErrSelfApprovalBlocked
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil { return false, "", err }
|
||||
if user.GlobalRole == "global_admin" {
|
||||
return true, "admin_override", nil
|
||||
}
|
||||
membership, err := s.projects.MembershipFor(ctx, callerID, projectID)
|
||||
if err != nil || membership == nil {
|
||||
return false, "", nil // not on team, cannot approve
|
||||
}
|
||||
if levelOf(membership.Role) >= levelOf(requiredRole) {
|
||||
return true, "peer", nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
```
|
||||
|
||||
`decision_kind` values: `peer` (normal in-team sign-off), `admin_override` (global_admin used override path). Stored on `approval_requests.decision_kind`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle flow (write-then-approve)
|
||||
|
||||
### 4.1 The four lifecycle events
|
||||
|
||||
For each entity (deadline, appointment), four lifecycle events trigger an approval check:
|
||||
|
||||
1. **create** — new row submitted by user.
|
||||
2. **update** — change to one or more date-bearing fields (allowlist in §4.5).
|
||||
3. **complete** — flip status from `pending` to `completed` on a deadline; flip new `completed_at` (see §6.4) on appointment.
|
||||
4. **delete** — request to remove the row.
|
||||
|
||||
### 4.2 Submission
|
||||
|
||||
User clicks Save / Complete / Delete on the entity. The service layer:
|
||||
|
||||
1. Looks up `paliad.approval_policies(project_id, entity_type, event)`.
|
||||
2. **No row found:** apply mutation immediately (today's behaviour). `approval_status` defaults to `'approved'`. No request row written. Done.
|
||||
3. **Row found:** apply mutation **except for delete** (see §4.3) and additionally:
|
||||
- Set `approval_status = 'pending'` and `pending_request_id = <new uuid>` on the entity row.
|
||||
- Insert one `paliad.approval_requests` row with `lifecycle_event`, `pre_image jsonb` (a snapshot of the now-overwritten field values, used for revert on rejection — see §4.4), `payload jsonb` (echo of what was submitted, for audit), `requested_by = caller`, `requested_at = now()`, `required_role = policy.required_role`, `status = 'pending'`.
|
||||
- Emit `paliad.project_events` row with `event_type = 'deadline_approval_requested'` (or `appointment_approval_requested`) carrying `metadata.approval_request_id = <uuid>`. The Verlauf shows the lifecycle inline.
|
||||
- All four writes happen in **one transaction** (entity update + request insert + event emit).
|
||||
4. **Single-qualified-approver deadlock check.** Before committing, the service runs a count: how many users on this project's team have `levelOf(project_teams.role) >= levelOf(required_role) AND user_id != caller`? If 0, the submission **fails with HTTP 409** and a structured error: `{ "error": "no_qualified_approver", "required_role": "associate", "hint": "add_team_member_or_contact_admin" }`. Frontend translates to a user-facing dialog with two action buttons: "Mehr Team-Mitglieder hinzufügen" (jumps to project team page) and "Admin kontaktieren" (mailto link to global_admin emails). global_admin override is the escape hatch (§4.7).
|
||||
|
||||
### 4.3 Delete is special — stage-then-write
|
||||
|
||||
m's chosen architecture is write-then-approve, but delete cannot be applied immediately and reverted: a hard-delete is irrecoverable.
|
||||
|
||||
**Resolution:** for `lifecycle_event = 'delete'`, the entity row stays in place. We set `approval_status = 'pending'` and link to an `approval_requests` row carrying `lifecycle_event = 'delete'`. The UI marks the row "Zur Löschung beantragt" (see §5.3). On approve: hard-delete the row in a tx (cascades clean up the FK from `approval_requests`). On reject: clear `approval_status` back to `'approved'` and `pending_request_id` to NULL. The deletion never happened.
|
||||
|
||||
This is the one departure from pure write-then-approve. It's a write-then-approve from the user's perspective (they "submit a delete" and the entity behaves as if it's about to disappear) but at the data-layer it's stage-then-write for delete. Documented explicitly to avoid surprise.
|
||||
|
||||
### 4.4 Approval / rejection
|
||||
|
||||
Approver opens `/inbox`, picks a request, clicks Approve (or Reject with optional reason).
|
||||
|
||||
**Approve:**
|
||||
|
||||
1. Service-layer `canApprove(caller, project, request)` check (see §3.4).
|
||||
2. If `decision_kind = 'peer'` or `'admin_override'`, set `approval_requests.status = 'approved'`, `decided_by = caller`, `decided_at = now()`, `decision_kind = …`.
|
||||
3. Update entity row: `approval_status = 'approved'`, clear `pending_request_id`. Set `approved_by = caller`, `approved_at = now()`.
|
||||
4. For `delete`: hard-delete the entity (cascade clears the request FK).
|
||||
5. Emit `paliad.project_events` row with `event_type = 'deadline_approval_approved'` (or `appointment_approval_approved`) carrying `metadata.approval_request_id`, `metadata.decision_kind`. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06".
|
||||
6. Tx commits.
|
||||
|
||||
**Reject:**
|
||||
|
||||
1. Same `canApprove` check.
|
||||
2. Set `approval_requests.status = 'rejected'`, `decided_by`, `decided_at`, `decision_note` (optional reason text from approver).
|
||||
3. **Revert entity** — restore from `pre_image`:
|
||||
- `create`: hard-delete the entity (it never should have been live).
|
||||
- `update`: write `pre_image` field values back over the row.
|
||||
- `complete`: revert deadline `status` to `'pending'`, NULL `completed_at`. Revert appointment `completed_at` to NULL (only meaningful once §6.4 lands).
|
||||
- `delete`: clear `pending_request_id` and `approval_status`. Entity stays live as before.
|
||||
4. Emit `paliad.project_events` row `event_type = 'deadline_approval_rejected'` (or appointment_) with `metadata.approval_request_id`, `metadata.decision_note`. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best."
|
||||
5. Tx commits.
|
||||
|
||||
### 4.5 Edit-trigger field allowlist (per Q4)
|
||||
|
||||
The service layer only enters the approval-request flow when an `update` touches the date-bearing fields. All other edits apply immediately as `approval_status='approved'` writes — no request row, no pending state.
|
||||
|
||||
**Deadlines — date-bearing (gates approval):**
|
||||
- `due_date`
|
||||
- `original_due_date`
|
||||
- `warning_date`
|
||||
|
||||
**Deadlines — bypass (no approval):**
|
||||
- `title`, `description`, `notes`
|
||||
- `rule_id`, `rule_code` (legal-basis citation — m chose to bypass; see Q4 trade-off below)
|
||||
- `event_type_ids` (Typ tags via `paliad.deadline_event_types` junction)
|
||||
- `status` other than via the `complete` lifecycle (e.g. cancel, waive — these are out of approval scope per the issue's "all four lifecycle events" framing, which lists complete but not cancel/waive)
|
||||
|
||||
**Appointments — date-bearing (gates approval):**
|
||||
- `start_at`
|
||||
- `end_at`
|
||||
|
||||
**Appointments — bypass (no approval):**
|
||||
- `title`, `description`
|
||||
- `location` (m's Q4 choice excludes location; documented trade-off below)
|
||||
- `appointment_type`
|
||||
|
||||
**Trade-off (m's call):** the looser allowlist means a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) won't trigger 4-eye. m's reasoning is implicit but consistent: dates are the highest-stakes mistake category (missed deadline = malpractice exposure), and gating every metadata edit creates approval fatigue that makes approvers rubber-stamp.
|
||||
|
||||
If the team finds this allowlist too loose in practice, the constants in `internal/services/approval_fields.go` (proposed location) are a one-PR widening — no schema change.
|
||||
|
||||
### 4.6 Optimistic-concurrency / superseded requests
|
||||
|
||||
Race scenario: User A submits an `update` request with `pre_image = {due_date: 2026-05-10}`. Before it's approved, user B submits another `update` with their own pre-image. Now there are two pending requests on the same row.
|
||||
|
||||
**Rule:** a row can have at most one pending request at a time. The submission service-layer does:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.deadlines
|
||||
SET ...new field values..., approval_status = 'pending', pending_request_id = $newRequestID
|
||||
WHERE id = $entityID
|
||||
AND approval_status = 'approved' -- only mutate if currently clean
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
If the UPDATE returns 0 rows (because `approval_status != 'approved'`), the submission fails with HTTP 409 `{ "error": "concurrent_pending", "hint": "wait_for_existing_approval_or_revoke" }`. Frontend shows "Es liegt bereits eine Genehmigungsanfrage auf dieser Frist vor."
|
||||
|
||||
Submitter has options: revoke their own pending (if they own it) and resubmit; or wait for the existing request to settle.
|
||||
|
||||
### 4.7 Single-qualified-approver deadlock — global_admin override path
|
||||
|
||||
Per Q8, the default behaviour is **refuse to submit** when no qualified approver other than the requester exists on the team. Submission is blocked at the API layer.
|
||||
|
||||
**Override mechanism:** any `global_admin` (regardless of project membership) has the approval right. So if the user's team has nobody else qualified, the user can submit anyway IF the project has at least one global_admin who can approve. The submission service runs the deadlock check as:
|
||||
|
||||
```
|
||||
SELECT COUNT(*) FROM paliad.project_teams pt
|
||||
WHERE pt.project_id = $proj
|
||||
AND pt.user_id <> $caller
|
||||
AND pt.role IN (eligible roles for required_role)
|
||||
+
|
||||
SELECT COUNT(*) FROM paliad.users u
|
||||
WHERE u.global_role = 'global_admin'
|
||||
AND u.id <> $caller
|
||||
```
|
||||
|
||||
If sum > 0, submission is allowed. If sum = 0, the 409 fires. In practice, paliad currently has 2 global_admins so sum is rarely 0 — but the design contemplates the case.
|
||||
|
||||
When global_admin signs off, the `decision_kind` on the approval_request row is `'admin_override'` (vs `'peer'`). Verlauf chronology renders this distinctly: "Admin-Sign-off von m · 2026-05-06" rather than "Genehmigt von Bert · 2026-05-06". The audit log timeline filters can pivot on `decision_kind`.
|
||||
|
||||
### 4.8 Revocation (per Q7)
|
||||
|
||||
- **Requester revokes:** while `request.status = 'pending'`, the requester can DELETE their own request. Service-layer reverts the entity from pre_image (same code path as Reject), but instead of marking the request `'rejected'`, marks it `'revoked'`. New `paliad.project_events` event_type `'deadline_approval_revoked'`.
|
||||
- **Approver revokes after approval:** **not supported** per Q7. Once approved, the only path back is a new request — e.g. an over-eager Complete is reversed by a fresh "Reopen" lifecycle event, which itself flows through the approval gate.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI surfaces
|
||||
|
||||
### 5.1 The pending pill — visible everywhere
|
||||
|
||||
Per m's interjection, pending state must surface in every view that shows the entity. Visual treatment:
|
||||
|
||||
- **Pending CREATE** — striped/dashed border on the row, ⚠ icon, label "Erstellung wartet auf Genehmigung von <required_role>+". Counted toward traffic-light buckets (the deadline IS real, just unverified) but rendered with a "tentative" CSS class.
|
||||
- **Pending UPDATE** — solid border, but a yellow chip in the date column saying "Datum geändert — wartet auf Genehmigung". Tooltip on the chip shows the diff: "vorher: 2026-05-10 → 2026-05-12".
|
||||
- **Pending COMPLETE** — solid border, status badge "Erledigt (wartet auf Genehmigung)" with strike-through-pending styling. The traffic-light treats the row as completed (the action-taker thinks they're done) but with the same striped class as create-pending so an approver can see the queue at a glance.
|
||||
- **Pending DELETE** — dashed-red border, label "Zur Löschung beantragt". Date / details still visible but strike-through. Click → details + approval request.
|
||||
|
||||
CSS classes (proposed, in `frontend/src/styles/global.css`):
|
||||
|
||||
```css
|
||||
.entity-row--pending-create { border-style: dashed; border-color: var(--frist-amber); }
|
||||
.entity-row--pending-update { /* solid border, chip handles the signal */ }
|
||||
.entity-row--pending-complete { background: linear-gradient(...striped...); }
|
||||
.entity-row--pending-delete { border-style: dashed; border-color: var(--frist-red); text-decoration: line-through; }
|
||||
|
||||
.approval-pill { display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 9999px;
|
||||
background: var(--bg-warn-soft); color: var(--fg-warn);
|
||||
font-size: 12px; }
|
||||
.approval-pill::before { content: "⚠ "; }
|
||||
```
|
||||
|
||||
i18n keys (DE primary, EN secondary):
|
||||
|
||||
- `approvals.pending_create.label` — "Erstellung wartet auf Genehmigung" / "Awaits approval (creation)"
|
||||
- `approvals.pending_update.label` — "Änderung wartet auf Genehmigung" / "Awaits approval (change)"
|
||||
- `approvals.pending_complete.label` — "Erledigung wartet auf Genehmigung" / "Awaits approval (completion)"
|
||||
- `approvals.pending_delete.label` — "Zur Löschung beantragt" / "Awaits approval (deletion)"
|
||||
- `approvals.required_role.<role>` — "Lead", "Of Counsel", "Associate", "Senior PA", "PA"
|
||||
- `approvals.requested_by` — "Eingereicht von {name}" / "Submitted by {name}"
|
||||
- `approvals.no_approver_dialog.*` — full deadlock dialog strings
|
||||
- `approvals.approve.button` — "Genehmigen" / "Approve"
|
||||
- `approvals.reject.button` — "Ablehnen" / "Reject"
|
||||
- `approvals.revoke.button` — "Zurückziehen" / "Revoke"
|
||||
- `approvals.decision_kind.peer` — "Genehmigt von {name}" / "Approved by {name}"
|
||||
- `approvals.decision_kind.admin_override` — "Admin-Sign-off von {name}" / "Admin sign-off by {name}"
|
||||
|
||||
Surfaces that show the pending pill:
|
||||
|
||||
- `/deadlines` and `/appointments` table rows (one pill per row).
|
||||
- `/agenda` timeline (per-row pill).
|
||||
- `/dashboard` traffic-light card-list previews.
|
||||
- `/projects/{id}` details — Fristen + Termine sections.
|
||||
- `/deadlines/{id}` and `/appointments/{id}` detail pages — full diff display.
|
||||
- CalDAV: pending entries sync to the user's external calendar with title prefix `[PENDING] ` (e.g. `[PENDING] Frist Erwiderung`). Approved entries sync clean.
|
||||
- Email reminders (`internal/services/reminder_service.go`): pending entries get a banner in the mail body and a `[PENDING] ` subject prefix.
|
||||
|
||||
### 5.2 Bell + `/inbox` page (per Q6)
|
||||
|
||||
**Bell** in the sidebar header (next to the user-menu). Shows count of "open requests where I am a qualified approver and not the requester". Click → `/inbox`. Refreshes via the existing dashboard-polling pattern (60s interval; `Last-Modified` ETag if cheap to add).
|
||||
|
||||
**`/inbox` page**, two tabs:
|
||||
|
||||
1. **"Zur Genehmigung"** (`?tab=pending-mine`): list of `approval_requests` where:
|
||||
- `status = 'pending'`
|
||||
- `requested_by != me`
|
||||
- I have eligible role on the project (or I'm global_admin)
|
||||
Sorted by `requested_at` ASC (oldest first — stale requests demand attention). Each item shows: project title, entity title, lifecycle event, requester name, age ("vor 4h"), required-role badge. Inline Approve / Reject buttons, expand-row reveals the diff (for update / complete / delete) or full payload (for create).
|
||||
|
||||
2. **"Meine Anfragen"** (`?tab=mine`): list of `approval_requests` where `requested_by = me`. Status filter pills: pending / approved / rejected / revoked. For pending items, a Revoke button.
|
||||
|
||||
URL structure: `/inbox?tab=pending-mine|mine&status=pending|...&project_id=...`. Back-button friendly.
|
||||
|
||||
Why distinct from email reminder flow: email reminders are outbound notifications (digest of upcoming deadlines). The inbox is a workflow surface — actions are taken there. Sharing infra would conflate two purposes.
|
||||
|
||||
### 5.3 Policy authoring — `/projects/{id}/settings/approvals`
|
||||
|
||||
Tab on the project detail page, gated to global_admin. Rendered as a 2×4 table:
|
||||
|
||||
```
|
||||
CREATE UPDATE (date) COMPLETE DELETE
|
||||
Frist [select] [select] [select] [select]
|
||||
Termin [select] [select] [select] [select]
|
||||
```
|
||||
|
||||
Each `<select>` offers: "Keine Genehmigung erforderlich (default)" / "Lead" / "Of Counsel" / "Associate" / "Senior PA" / "PA". Submitting upserts/deletes rows in `paliad.approval_policies`.
|
||||
|
||||
Helpers:
|
||||
- "Aus Eltern-Projekt übernehmen" button — copies the parent project's policy rows in one click. One-shot copy, no live link.
|
||||
- "Alle auf Associate setzen" button — fills all 8 cells with `associate` for fast onboarding of a new project.
|
||||
|
||||
### 5.4 Diff rendering
|
||||
|
||||
For `update` requests, the `pre_image` jsonb captured at submission and the entity's current values let the UI render a clean diff. For deadlines: a 1-3 line comparison ("Datum: 2026-05-10 → 2026-05-12 · Warnung: 2026-05-08 → 2026-05-10"). Done in pure TS in `frontend/src/client/inbox.ts` consuming the request payload.
|
||||
|
||||
---
|
||||
|
||||
## 6. Schema changes (migration 054)
|
||||
|
||||
### 6.1 Add `senior_pa` to `project_teams.role`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
|
||||
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
|
||||
CHECK (role IN (
|
||||
'lead','associate','pa','of_counsel',
|
||||
'local_counsel','expert','observer',
|
||||
'senior_pa'
|
||||
));
|
||||
```
|
||||
|
||||
i18n labels for the new role (in DE+EN per existing `team.role.*` keys).
|
||||
|
||||
### 6.2 `paliad.approval_policies`
|
||||
|
||||
See §3.1 — full DDL.
|
||||
|
||||
### 6.3 `paliad.approval_requests`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.approval_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
|
||||
-- entity_id is the deadline.id / appointment.id this request operates on.
|
||||
-- For 'create' lifecycle, this is the id of the just-inserted entity row
|
||||
-- (so the request can reference back to it). For 'delete', it's the row
|
||||
-- being requested for removal.
|
||||
entity_id uuid NOT NULL,
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
|
||||
-- For 'update'/'complete'/'delete': pre_image carries the field values
|
||||
-- needed to revert on rejection. For 'create': pre_image IS NULL.
|
||||
pre_image jsonb,
|
||||
-- For audit/visibility, payload echoes the diff or new values that were
|
||||
-- written. Read-only after insert.
|
||||
payload jsonb,
|
||||
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Snapshot of policy.required_role at request time. Even if the policy
|
||||
-- changes mid-flight, the request honours the level it was submitted under.
|
||||
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','approved','rejected','revoked','superseded')),
|
||||
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
decided_at timestamptz,
|
||||
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer','admin_override')),
|
||||
decision_note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Hard CHECK: an approver is never the requester.
|
||||
CHECK (decided_by IS NULL OR decided_by <> requested_by)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_requests_project_status_idx
|
||||
ON paliad.approval_requests (project_id, status);
|
||||
CREATE INDEX approval_requests_entity_idx
|
||||
ON paliad.approval_requests (entity_type, entity_id);
|
||||
CREATE INDEX approval_requests_requested_by_idx
|
||||
ON paliad.approval_requests (requested_by, status);
|
||||
CREATE INDEX approval_requests_pending_idx
|
||||
ON paliad.approval_requests (status, requested_at)
|
||||
WHERE status = 'pending';
|
||||
```
|
||||
|
||||
RLS on `approval_requests`: per Q10, mirror `paliad.deadlines` policy — visible if `paliad.can_see_project(project_id)`. RLS does NOT gate the approve/reject action; that's enforced at the service layer.
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY approval_requests_all ON paliad.approval_requests
|
||||
FOR ALL USING (paliad.can_see_project(project_id));
|
||||
```
|
||||
|
||||
### 6.4 New columns on `paliad.deadlines` and `paliad.appointments`
|
||||
|
||||
```sql
|
||||
-- deadlines: approval state + approver tracking
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved','pending','legacy'));
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approved_by uuid
|
||||
REFERENCES paliad.users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approved_at timestamptz;
|
||||
|
||||
CREATE INDEX deadlines_approval_status_idx
|
||||
ON paliad.deadlines (approval_status) WHERE approval_status = 'pending';
|
||||
|
||||
-- appointments: same triple
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved','pending','legacy'));
|
||||
ALTER TABLE paliad.appointments ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approved_by uuid
|
||||
REFERENCES paliad.users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approved_at timestamptz;
|
||||
|
||||
-- appointments need a completed_at for the 'complete' lifecycle event to land
|
||||
ALTER TABLE paliad.appointments ADD COLUMN completed_at timestamptz;
|
||||
|
||||
CREATE INDEX appointments_approval_status_idx
|
||||
ON paliad.appointments (approval_status) WHERE approval_status = 'pending';
|
||||
```
|
||||
|
||||
**`appointments.completed_at`** is new. Today appointments don't have a completion concept — they just sit on the calendar. The `complete` lifecycle event for appointments is meaningful when m wants to mark hearings/meetings as actually-happened (e.g. "Mündliche Verhandlung am 2026-05-15 — abgehalten"). If m prefers to drop appointment-complete from the lifecycle list (deadline-complete only), the `completed_at` column drops out and the policy CHECK constraint excludes `(appointment, complete)`.
|
||||
|
||||
This is a clean place for m to make a smaller call: keep appointment:complete (and add `completed_at`), or drop it.
|
||||
|
||||
### 6.5 Backfill
|
||||
|
||||
```sql
|
||||
-- Mark all existing rows as legacy (predates 4-eye).
|
||||
UPDATE paliad.deadlines SET approval_status = 'legacy';
|
||||
UPDATE paliad.appointments SET approval_status = 'legacy';
|
||||
```
|
||||
|
||||
`approved_by`/`approved_at` stay NULL on legacy rows. `created_by` is already populated since migration 005 (the column has been required from day one).
|
||||
|
||||
**No retroactive approval** — m's Q11 choice. Legacy rows are read-only-clean. The next mutation on a legacy row that hits an active policy follows the normal flow (e.g. editing a date on a legacy deadline triggers `update` approval; the row becomes `approval_status='pending'` and goes through the gate; once approved, `approval_status='approved'`).
|
||||
|
||||
### 6.6 Down migration
|
||||
|
||||
The down migration drops the four new columns + `completed_at` + `approval_policies` + `approval_requests` + restores the `project_teams.role` CHECK without `senior_pa`. If any user has been re-roled to `senior_pa`, the down migration will fail loudly until they're migrated to another role — intentional, mirrors the t-paliad-051 down strategy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Service-layer integration
|
||||
|
||||
### 7.1 New service: `ApprovalService`
|
||||
|
||||
```go
|
||||
// internal/services/approval_service.go
|
||||
|
||||
type ApprovalService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// SubmitCreate is invoked by DeadlineService.Create / AppointmentService.Create
|
||||
// inside the existing entity-write tx. If a policy applies, it inserts the
|
||||
// approval_requests row and sets entity.approval_status = 'pending' + entity.
|
||||
// pending_request_id. Returns (requestID, isPending, err).
|
||||
func (s *ApprovalService) SubmitCreate(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
|
||||
// Same shape for Update / Complete / Delete. Update takes a preImage map.
|
||||
func (s *ApprovalService) SubmitUpdate(ctx, tx, projectID, entityType, entityID, requesterID, preImage map[string]any) (uuid.UUID, bool, error)
|
||||
func (s *ApprovalService) SubmitComplete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
func (s *ApprovalService) SubmitDelete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
|
||||
// Approve / Reject / Revoke — invoked by the inbox handler.
|
||||
func (s *ApprovalService) Approve(ctx, requestID, callerID, note string) error
|
||||
func (s *ApprovalService) Reject(ctx, requestID, callerID, note string) error
|
||||
func (s *ApprovalService) Revoke(ctx, requestID, callerID string) error
|
||||
|
||||
// ListInbox returns the pending-mine and my-submitted views.
|
||||
func (s *ApprovalService) ListPendingForApprover(ctx, callerID, filter) ([]ApprovalRequestView, error)
|
||||
func (s *ApprovalService) ListSubmittedByUser(ctx, callerID, filter) ([]ApprovalRequestView, error)
|
||||
```
|
||||
|
||||
### 7.2 Wiring into existing services
|
||||
|
||||
**`DeadlineService.Create`** today:
|
||||
1. ProjectService.GetByID gate (visibility check)
|
||||
2. Begin tx
|
||||
3. INSERT into paliad.deadlines
|
||||
4. Attach event_types junction rows
|
||||
5. insertProjectEventWithMeta(deadline_created)
|
||||
6. Commit
|
||||
|
||||
After integration:
|
||||
1. ProjectService.GetByID gate
|
||||
2. Begin tx
|
||||
3. INSERT into paliad.deadlines (approval_status defaults to 'approved')
|
||||
4. **`approvals.SubmitCreate(ctx, tx, projectID, "deadline", id, userID)`** — if policy applies, this:
|
||||
- Updates approval_status='pending', pending_request_id=… on the just-inserted row
|
||||
- INSERTs approval_requests row
|
||||
- Performs deadlock count, fails the tx if 0 qualified approvers exist
|
||||
5. Attach event_types junction rows
|
||||
6. insertProjectEventWithMeta(deadline_created) — unchanged
|
||||
7. **insertProjectEventWithMeta(deadline_approval_requested)** if approval is pending
|
||||
8. Commit
|
||||
|
||||
Same shape for `Update`, `Complete`, `Delete` on both DeadlineService and AppointmentService. The `Complete` call site is `MarkComplete`/`Reopen` in DeadlineService (today); reopen would be modelled as a fresh "create-style" approval if it lands on a legacy row, or as part of "update" lifecycle on the `status` field — but `status` is not in the date-bearing allowlist so reopen goes through immediately. **Reopen does NOT trigger 4-eye** under this design (Q4 = date-fields-only). If m wants reopen-needs-approval, add `status` to the allowlist or treat reopen as its own lifecycle event.
|
||||
|
||||
### 7.3 Read-path changes
|
||||
|
||||
Existing list/summary queries (`ListVisibleForUser`, `SummaryCounts`) need to:
|
||||
|
||||
- Hydrate `approval_status`, `approved_by`, `approved_at`, and the linked `approval_requests.lifecycle_event` (via JOIN) for each row.
|
||||
- Pass these through to the frontend so the pending pill and traffic-light styling can render.
|
||||
|
||||
Bucket math (t-paliad-106 5-bucket harmonisation) is **unchanged** — pending CREATEs still bucket by `due_date` like any other; the visual just adds the pending pill. Pending DELETEs still appear in their bucket until the delete is approved.
|
||||
|
||||
`/api/inbox/pending-mine` and `/api/inbox/mine` are new endpoints, served by `internal/handlers/inbox.go`.
|
||||
|
||||
### 7.4 Visibility gating for the inbox
|
||||
|
||||
The pending-mine list is gated by:
|
||||
|
||||
```sql
|
||||
SELECT ar.* FROM paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
WHERE ar.status = 'pending'
|
||||
AND ar.requested_by != $callerID
|
||||
AND <visibilityPredicate>(p) for callerID
|
||||
AND (
|
||||
-- caller is global_admin
|
||||
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin')
|
||||
OR
|
||||
-- caller has eligible role on this specific project
|
||||
EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $callerID
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND levelOf(pt.role) >= levelOf(ar.required_role))
|
||||
)
|
||||
ORDER BY ar.requested_at ASC;
|
||||
```
|
||||
|
||||
`levelOf` in SQL is a small immutable function:
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION paliad.approval_role_level(role text) RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
```
|
||||
|
||||
Stable values; mirrors the Go `levelOf`. Used in the inbox SQL and in any future RLS policy. Migration ships both.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit / chronology integration
|
||||
|
||||
Per Q9, the existing `paliad.project_events` audit gains four new event_type values per entity:
|
||||
|
||||
- `deadline_approval_requested` — a request was submitted. Metadata: `{ approval_request_id, lifecycle_event, required_role }`.
|
||||
- `deadline_approval_approved` — request approved. Metadata: `{ approval_request_id, decision_kind, decided_by_email }`.
|
||||
- `deadline_approval_rejected` — request rejected. Metadata: `{ approval_request_id, decision_note }`.
|
||||
- `deadline_approval_revoked` — requester revoked their own pending. Metadata: `{ approval_request_id }`.
|
||||
|
||||
Same four for appointments (`appointment_approval_*`).
|
||||
|
||||
These appear in:
|
||||
|
||||
- The `paliad.project_events` Verlauf card on `/projects/{id}` (via existing render path; new translateEvent cases needed in `frontend/src/client/projects-detail.ts`).
|
||||
- The `paliad.project_events` Verlauf card on `/deadlines/{id}` and `/appointments/{id}` (same pattern).
|
||||
- The cross-project `AuditService.ListEntries` timeline at `/admin/audit-log` (already unions project_events; new event types ride along automatically).
|
||||
- Dashboard recent-activity rail (filter through existing `translateEvent` to render the correct sentence).
|
||||
|
||||
**Both names persist on the entity** per the issue's m-locked requirement: `created_by` (already there) + `approved_by` (new). Verlauf renders for an approved deadline:
|
||||
|
||||
```
|
||||
Frist erstellt — eingereicht von Anna 2026-05-06 14:23
|
||||
· genehmigt von Bert 2026-05-06 14:31
|
||||
```
|
||||
|
||||
This is two project_events rows rendered as a paired card in the Verlauf. The frontend pairs them by `metadata.approval_request_id`.
|
||||
|
||||
---
|
||||
|
||||
## 9. RLS / security plan
|
||||
|
||||
Per Q10:
|
||||
|
||||
1. **`approval_requests`** — RLS = `paliad.can_see_project(project_id)`. Same predicate as `deadlines`/`appointments`. Anyone on the project can read pending requests (transparency).
|
||||
2. **`approval_policies`** — RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to `global_role = 'global_admin'` (consistent with /admin/team / /admin/partner-units precedent).
|
||||
3. **The `approve`/`reject`/`revoke` action** — service-layer gate only. The pgx pool runs as service role and bypasses RLS, so the check happens in `ApprovalService.canApprove()` (§3.4). RLS provides defense-in-depth for any future direct-DB query path.
|
||||
4. **Self-approval block** — enforced both at the service layer and via a CHECK constraint on `approval_requests` (`decided_by IS NULL OR decided_by <> requested_by`). Two layers because either alone is insufficient (a SQL bug bypasses the service; a service bug bypasses the CHECK).
|
||||
|
||||
The path-walking team-membership + global_admin predicate (`visibilityPredicate`) extends naturally to "approvable-by-me" via the inline JOIN shown in §7.4. No new SQL function needed; the inline form is read-only on the inbox query path.
|
||||
|
||||
**Out of scope follow-up:** if any future direct-DB tooling needs to query "approvable by me", extract a `paliad.can_approve_in_project(user_id, project_id, required_role)` SQL function. For v1, the inline JOIN is sufficient and avoids adding a function that no migration currently calls.
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration plan
|
||||
|
||||
### 10.1 Single migration, single PR
|
||||
|
||||
Migration 054 (`054_approvals.{up,down}.sql`):
|
||||
|
||||
1. Add `senior_pa` to `project_teams.role` CHECK (§6.1).
|
||||
2. Create `paliad.approval_role_level(text) RETURNS int` SQL function.
|
||||
3. Create `paliad.approval_policies` table (§6.2) + indexes + RLS.
|
||||
4. Create `paliad.approval_requests` table (§6.3) + indexes + RLS.
|
||||
5. Add new columns on `paliad.deadlines` and `paliad.appointments` (§6.4) + indexes.
|
||||
6. Mark all existing rows `approval_status='legacy'` (§6.5).
|
||||
|
||||
No data move. No FK hijinks. ms-level apply on a 200-ish-row deadlines table.
|
||||
|
||||
### 10.2 Implementation phasing
|
||||
|
||||
The PR is large but clean. Recommended split into commits (single branch, single PR):
|
||||
|
||||
1. **Commit 1 — Migration 054.** Schema + backfill. No code changes. Runs cleanly on prod; existing flows don't read the new columns yet.
|
||||
2. **Commit 2 — `ApprovalService` core.** Submit / Approve / Reject / Revoke, deadlock check, pre_image capture, request lifecycle. Unit tests (table-driven over the strict-ladder + self-approval rules, deadlock count edge cases).
|
||||
3. **Commit 3 — Wire into `DeadlineService` + `AppointmentService`.** Mutation paths gain the SubmitCreate/Update/Complete/Delete hooks. Read paths hydrate approval_status. Adds new event_types to project_events emit path. Live-DB integration test: TEST_DATABASE_URL covering submit→approve / submit→reject / submit→revoke / single-approver-deadlock / global-admin-override.
|
||||
4. **Commit 4 — Policy authoring page.** `/projects/{id}/settings/approvals` tab + handler + frontend. global_admin-only gate.
|
||||
5. **Commit 5 — Inbox.** `/inbox` page + bell icon + `/api/inbox/*` endpoints + frontend list rendering with diff display.
|
||||
6. **Commit 6 — Pending pills + traffic-light variants.** CSS + i18n + per-surface pill rendering on /deadlines, /appointments, /agenda, /dashboard, /projects/{id}, detail pages.
|
||||
7. **Commit 7 — CalDAV `[PENDING] ` prefix + email-reminder pending banner.** Updates `caldav_service.go` and `mail_service.go` formatting. Integration tests on iCal output and rendered email body.
|
||||
8. **Commit 8 — Verlauf rendering of approval lifecycle.** translateEvent cases for the four new event_types. Pair-card rendering for request+decision events.
|
||||
|
||||
Each commit is testable in isolation; commits 1–3 are merge-safe even before the UI lands (legacy rows + pending state hidden by default = no behaviour change on existing flows because no project has policies until commit 4 ships).
|
||||
|
||||
### 10.3 Roll-out
|
||||
|
||||
Suggested:
|
||||
|
||||
1. Migration 054 lands → no behaviour change (no policies exist yet).
|
||||
2. Pick one pilot project, set policy `(deadline,*)=associate`. Smoke through one CREATE / UPDATE / COMPLETE / DELETE cycle as a non-admin user. Verify pending pills, inbox, approver flow, audit chronology.
|
||||
3. Once validated, m authors policies on real client projects. Each project opts in by adding rows.
|
||||
4. Backfill any free-form leftover later if needed (admin scripts).
|
||||
|
||||
---
|
||||
|
||||
## 11. Trade-offs and known limitations
|
||||
|
||||
### 11.1 Write-then-approve dilution risk
|
||||
|
||||
Per Q5 m chose write-then-approve. This means a pending CREATE is "live" in lists / dashboard / agenda / CalDAV / email reminders before approval. A wrongful create that's eventually rejected briefly polluted the user's mental model and external calendars.
|
||||
|
||||
**Mitigations:**
|
||||
- Pending pill is highly visible (striped border, ⚠ icon).
|
||||
- CalDAV title prefix `[PENDING] ` makes external surfaces honest.
|
||||
- Rejected creates emit `*_approval_rejected` event in Verlauf so the "what happened to that deadline" question has a paper trail.
|
||||
- Approval flow surfaces immediately in inbox (bell badge), so latency between submit and approve is short.
|
||||
|
||||
The alternative (stage-then-write) was strictly safer but m rejected it; the strict-safer architecture would have forced each Frist to live in `approval_requests` until approved, which means views had to UNION the entity table with the requests table — heavy read-path changes and the kind of complexity that compounds into bugs.
|
||||
|
||||
### 11.2 Date-fields-only edit allowlist
|
||||
|
||||
m chose Q4 = "Only date-changing fields". Trade-off: a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) bypasses 4-eye. The ladder-based approval-fatigue argument (every metadata edit triggering approvals causes rubber-stamping) is the case for the looser scope.
|
||||
|
||||
If the team finds this too loose in practice, extending the allowlist is a one-line constants change in `internal/services/approval_fields.go` — documented as the place to widen.
|
||||
|
||||
### 11.3 No inheritance from parent project
|
||||
|
||||
§3.2 — a child project doesn't auto-inherit its parent's policy. Trade-off: explicit per-project authoring (more control, more clicks). The "Aus Eltern-Projekt übernehmen" button in the authoring UI (§5.3) reduces the friction.
|
||||
|
||||
### 11.4 v1 is global_admin-only for policy authoring
|
||||
|
||||
Per §3.3, only global_admins can create/edit policies. Project leads cannot edit their own project's policy. Trade-off: tighter governance vs. lead self-service. Lifting to "lead can edit" is a one-line gate change (file as t-paliad-139).
|
||||
|
||||
### 11.5 senior_pa is the only new role enum value
|
||||
|
||||
§6.1 only adds `senior_pa`. Other firm-rank candidates from the issue (`partner`, `senior_attorney`, `attorney`, `paralegal`) were redundant: `lead` already represents partner-tier on a project, `of_counsel` covers senior-attorney, `associate` covers attorney, and paralegal sits below pa (mapped to `observer` in v1). If those distinctions matter later, additional values can be added without breaking existing rows.
|
||||
|
||||
### 11.6 Reopen is not a separate lifecycle
|
||||
|
||||
Today reopening a deadline (revert from `completed` to `pending`) is a status-only change. With Q4 = date-fields-only, reopen does NOT trigger 4-eye. If m wants reopen-needs-approval, it can be modelled as a 5th lifecycle event or as a special-case status-field entry in the allowlist. Documented for future tightening.
|
||||
|
||||
### 11.7 Approval timeout
|
||||
|
||||
No automatic timeout on pending requests. A request can sit pending forever. UI surfaces age ("vor 4 Tagen") to nudge approvers. Future addition: nightly digest email to approvers with a list of pending items > 24h old. Out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation recommendation
|
||||
|
||||
Recommended implementer: **cronus** (this same worktree). Rationale: shipped t-paliad-088 (Event Types — schema + service + handlers + frontend, similar shape), t-paliad-110 (events unification — read-path with new columns hydrated and rendered), t-paliad-122 (courts entity with role-tier-like ladder over countries+regimes). Pattern fluency is high.
|
||||
|
||||
Alternative: split — cronus does commits 1–3 (schema + service core + service-layer wiring) on `mai/cronus/approvals-impl-1`. Then a fresh coder (curie or fritz) does commits 4–8 (UI + inbox + pills + CalDAV + email) on a sibling branch. Trade-off: smaller PRs, but two coordination handovers.
|
||||
|
||||
Head decides.
|
||||
|
||||
---
|
||||
|
||||
## 13. End-of-design checklist
|
||||
|
||||
- [x] Locked constraints summarised (§0)
|
||||
- [x] Existing-code grounding (§1)
|
||||
- [x] Role taxonomy / hierarchy (§2)
|
||||
- [x] Rule grammar (§3)
|
||||
- [x] Lifecycle flow + edit allowlist + deadlock + revocation (§4)
|
||||
- [x] UI surfaces (§5)
|
||||
- [x] Schema (§6)
|
||||
- [x] Service-layer integration (§7)
|
||||
- [x] Audit / chronology (§8)
|
||||
- [x] RLS / security (§9)
|
||||
- [x] Migration plan + phasing (§10)
|
||||
- [x] Trade-offs (§11)
|
||||
- [x] Implementation recommendation (§12)
|
||||
|
||||
**Inventor stays parked.** Design committed; awaiting m's go/no-go before any coder shift starts. No `/mai-coder` self-load. The `DESIGN READY FOR REVIEW` signal is sent via `mai report completed` so the head can gate.
|
||||
888
docs/design-data-display-model-2026-05-06.md
Normal file
888
docs/design-data-display-model-2026-05-06.md
Normal file
@@ -0,0 +1,888 @@
|
||||
# Design: Data display model — additive Custom Views layer + unified inbox subsume + render-shape switcher
|
||||
|
||||
**Task:** t-paliad-144
|
||||
**Issue:** m/paliad#5
|
||||
**Author:** noether (inventor)
|
||||
**Date:** 2026-05-06
|
||||
**Status:** LOCKED 2026-05-07 — m signed off on all recommendations + §10 follow-ups, with one correction (Q4 narrowed from 4 shapes → 3; "activity" is a filter/source choice, not a render shape — folded into `list` shape with density config). Inventor → coder transition initiated. PR split chosen: A1 backend substrate, A2 frontend Custom Views.
|
||||
**Branch:** `mai/noether/inventor-data-display`
|
||||
**Builds on:** t-paliad-109 (events unification, shipped) + t-paliad-138 (approvals, shipped) + t-paliad-139 (hierarchy aggregation, all 3 phases on `mai/noether/inventor-project` awaiting merge gate)
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (read this first)
|
||||
|
||||
The issue body asks for a unified data-display model. Three premises in the brief that I verified against the live tree on this worktree before designing on top of them:
|
||||
|
||||
| Premise | Live state | Verdict |
|
||||
|---|---|---|
|
||||
| `EventService` is already a 2-source union over `paliad.deadlines` + `paliad.appointments` | `internal/services/event_service.go` lines 40–193 — `ListVisibleForUser` runs the deadline path then the appointment path then merges in Go, sorted by `event_date` | **confirmed**; substrate exists in miniature today |
|
||||
| `/agenda` is a separate timeline service, not the same code path | `internal/services/agenda_service.go` lines 78–128 — `AgendaService.List` independently joins deadlines + appointments. Different SQL, different projection (`AgendaItem` vs `EventListItem`), different urgency annotation. | **confirmed**; we have *two* substrates already, both 2-source. Generalising means picking one and retiring the other (or keeping both temporarily). |
|
||||
| `/inbox` is a 4-eye approval surface, not a generic activity feed | `frontend/src/inbox.tsx` (61 lines) + `internal/services/approval_service.go` lines 730–810 — two-tab UI ("Zur Genehmigung", "Meine Anfragen") backed by `ListPendingForApprover` / `ListSubmittedByUser`. | **confirmed**; today's `/inbox` is approval-only, not the unified-inbox concept m's brainstorm describes |
|
||||
| t-paliad-139 Phase 2 schema (migration 055) is incoming but not on main | Migration file exists at `internal/db/migrations/055_hierarchy_aggregation.up.sql`; per noether's prior memory, all 3 phases are stacked on `mai/noether/inventor-project` awaiting merge gate. | **confirmed**; this design must compose on top of 055's `paliad.project_partner_units` + `derive_grants_authority` model without forcing 139 to re-land |
|
||||
| `paliad.project_events` carries audit kinds (`project_created`, `status_changed`, `project_archived`, `project_reparented`, …) | `internal/services/project_service.go` lines 491–805 — five `insertProjectEvent` call-sites today; `event_type` column is free-text. | **confirmed**; `project_events` is the natural fourth data source for "what happened on my projects?" |
|
||||
|
||||
So the premises that anchor the design are sound. One correction to the issue body itself worth flagging:
|
||||
|
||||
> the issue body lists `paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.approval_requests` as the four current data tables.
|
||||
|
||||
That is right, but `event_service.go` only unions the **first two**. The Verlauf surface on `/projects/{id}` (project_events) and the inbox surface (approval_requests) are *each* their own bespoke endpoint today. The design below makes all four first-class `data_source` values in the substrate; flagging that the existing `EventService` will need to grow, not stay frozen.
|
||||
|
||||
---
|
||||
|
||||
## 1. m's intent (as I read it)
|
||||
|
||||
> "Custom views with saving them. […] If they could customize their view like 'myVerySpecialAgenda' with criteria and view options (filters, type of view — calendar vs cards vs list) and turn on parts — and then those views would be shown in the sidenavbar under a separate button. And on the page, the user can select all kinds of visuals."
|
||||
|
||||
Plus the locked direction of 2026-05-06 16:42:
|
||||
|
||||
- **Additive.** Fixed defaults stay; Custom Views ship alongside.
|
||||
- **Subsume the unified inbox.** Approval candidates + project activity + new cases + status changes — all viewable through the same substrate, with configurable granularity.
|
||||
- **Sidebar layout:** separate "Meine Sichten" group.
|
||||
- **In-page render-shape switcher.**
|
||||
- **paliad-only scope.**
|
||||
|
||||
Three design pieces fall out of this:
|
||||
|
||||
1. **A substrate** — one read API that returns rows from N data sources, filterable by one shared grammar.
|
||||
2. **A render layer** — a small set of presentation components (List, Cards, Calendar, Activity) that all consume the substrate's row shape.
|
||||
3. **A persistence + sidebar story** — `paliad.user_views` + a "Meine Sichten" group + URL contract `/views/{slug}`.
|
||||
|
||||
§§3–5 cover those three. §6 covers cross-cutting concerns (RLS, performance, migration). §10 lists open questions for m to answer before coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recommended design (TL;DR)
|
||||
|
||||
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|
||||
|---|---|---|
|
||||
| **Substrate shape** | One `ViewService` (new) that union-loads from 4 data sources: `deadline`, `appointment`, `project_event` (audit), `approval_request`. Returns a discriminated `[]ViewRow` keyed by `kind`. | Single virtual SQL `view_row` table with UNION ALL across all 4 — too many polymorphic columns; harder to evolve per-source filters. |
|
||||
| **Filter grammar** | Structured JSON spec validated server-side (`FilterSpec`). UI builds it via affordance widgets; the JSON is also human-editable for power users. | SQL DSL (security risk + complexity); UI-only (forces every dimension to have a widget). |
|
||||
| **Render shapes for v1** | `list`, `cards`, `calendar` (3). Activity-feed appearance is achieved by source/filter choice (`sources: ["project_event", …]`) rendered through `list` shape with `density: "compact"` + actor/time columns — *not* a separate shape. Defer `kanban`, `connections-graph`, `timeline-distinct-from-cards`. | Ship 4+ shapes including a dedicated "activity" — m's correction (2026-05-07): activity is content selection, not visualisation. Shape ⊥ source. |
|
||||
| **Persistence** | New table `paliad.user_views` (id, user_id, slug, name, filter_spec jsonb, render_spec jsonb, sort_order, icon, last_used_at, …). RLS = caller's own rows only. | Per-user JSON column on `paliad.users` — kills the sidebar count badge query path (`SELECT count(*) WHERE user_id`); also no indexed sort. |
|
||||
| **System defaults — code or DB?** | **Code.** Defaults stay as their own pages (`/dashboard`, `/agenda`, `/events`, `/inbox`); they are *built using the same render components* the custom-view system uses. No `is_system=true` row in `user_views`. | Seed system rows per user — drifts on schema bumps; new users miss bumps; `is_system=true` is a synonym for "config-as-data when config-as-code is cleaner". |
|
||||
| **Sidebar** | New "Meine Sichten" group between "Arbeit" and "Werkzeuge". Each saved view appears as one nav entry (icon + name). One trailing "+ Neue Sicht" entry. | "Meine Sichten" as a single sidebar entry expanding to a panel — extra click cost on every navigation. |
|
||||
| **In-page render-shape switcher** | A 4-button switcher on every view page (system + custom). Same component already exists on `/events` (cards/list/calendar). Generalise + add `activity`. | Per-route hardcoded shape — fights m's intent ("user can select all kinds of visuals"). |
|
||||
| **URL contract** | `/views/{slug}` for custom views (slug is user-scoped). System views keep their existing URLs. Filter overrides via query params, transient (don't mutate stored spec). | UUID URLs (`/views/{uuid}`) — unsharable, unbookmarkable. |
|
||||
| **`/inbox` page** | Stays as a fixed sidebar entry at the same URL. **Internally** refactored to use the new substrate as its read path, but the UI + URL stay. | Refactor /inbox away — needless break for users + email links. The locked direction is "subsume the inbox concept", which I read as substrate sharing, not URL retirement. |
|
||||
| **Approval-candidate visibility** | Approval requests are their own `data_source`; an inbox-shaped view picks `sources: ["approval_request"]`. Pending pills on entity rows are a separate concern (already shipped via `entity.approval_status='pending'`). | Predicate-only — collapses two genuinely-different shapes (the request row vs the entity row). |
|
||||
| **Migration / coexistence** | **Phase A:** ship substrate + render components + Custom Views + `paliad.user_views`. Existing pages untouched. **Phase B (later, separate task):** refactor system pages internally to use the substrate. | Refactor system pages in the same PR — bigger blast radius; harder to roll back. |
|
||||
| **Performance v1** | Run on every load. Cursor pagination (`event_date` + `id` tiebreaker). No materialised views. Add per-source row caps later if telemetry says so. | Materialised view per saved view — refresh complexity, drift risk, doesn't help the first load. |
|
||||
|
||||
The rest of this doc is the detail behind those rows.
|
||||
|
||||
---
|
||||
|
||||
## 3. Section A — Substrate: data sources + filter grammar (Q1–Q3, Q13)
|
||||
|
||||
### Q1 — What's the fundamental row?
|
||||
|
||||
**Recommendation: discriminated `ViewRow` projection over an explicit data-source registry.**
|
||||
|
||||
```go
|
||||
// internal/services/view_service.go (new)
|
||||
|
||||
type DataSource string
|
||||
|
||||
const (
|
||||
SourceDeadline DataSource = "deadline"
|
||||
SourceAppointment DataSource = "appointment"
|
||||
SourceProjectEvent DataSource = "project_event" // audit / Verlauf
|
||||
SourceApprovalRequest DataSource = "approval_request" // 4-eye inbox
|
||||
)
|
||||
|
||||
// ViewRow is the union shape served by the substrate. The shape is
|
||||
// projection-stable: every source fills the common header fields; type-
|
||||
// specific fields hang off `Detail` as a discriminated payload.
|
||||
type ViewRow struct {
|
||||
Kind DataSource `json:"kind"` // discriminator
|
||||
ID uuid.UUID `json:"id"` // source-row id
|
||||
Title string `json:"title"` // display title
|
||||
Subtitle *string `json:"subtitle,omitempty"` // short context line
|
||||
EventDate time.Time `json:"event_date"` // canonical sort key
|
||||
|
||||
// Project context — every row in paliad has a project (approval_requests
|
||||
// and project_events are project-attached by definition; deadlines and
|
||||
// appointments may be personal but inherit project context when set).
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
ProjectTitle *string `json:"project_title,omitempty"`
|
||||
ProjectReference *string `json:"project_reference,omitempty"`
|
||||
ProjectType *string `json:"project_type,omitempty"`
|
||||
|
||||
// Actor — who created this row (deadline/appointment) or who acted
|
||||
// on it (project_event author, approval_request requester).
|
||||
ActorID *uuid.UUID `json:"actor_id,omitempty"`
|
||||
ActorName *string `json:"actor_name,omitempty"`
|
||||
|
||||
// Detail carries the source-specific payload the render layer reads
|
||||
// when it needs more than the header (e.g. cards render the deadline
|
||||
// status pill, the calendar renders the appointment time range, the
|
||||
// activity feed renders the audit description).
|
||||
Detail json.RawMessage `json:"detail"` // shape determined by `kind`
|
||||
}
|
||||
```
|
||||
|
||||
`Detail` is a per-source typed Go struct (`DeadlineDetail`, `AppointmentDetail`, `ProjectEventDetail`, `ApprovalRequestDetail`) marshalled via `json.RawMessage` so the row stays a single struct on the wire. The frontend type-narrows on `kind`.
|
||||
|
||||
Why a registry over a single virtual SQL view:
|
||||
|
||||
- The four source tables have **truly disjoint columns** — deadline has `due_date` and `rule_code`, appointment has `start_at`/`end_at`/`location`, project_event has `event_type` (free text) + `metadata jsonb`, approval_request has `lifecycle_event` + `requested_at`. A `UNION ALL` materialised view ends up with ~40 nullable columns, half of them per row.
|
||||
- Per-source filtering is fundamentally different — deadline filters look at `status`, appointment filters look at `appointment_type`, project_event filters look at `event_type`, approval_request filters look at `lifecycle_event` + `status`. Translating those into one CHECK-style filter grammar is harder than running per-source SQL paths and merging.
|
||||
- The substrate already exists in miniature today — `event_service.go` line 114 union-loads two sources and merges in Go. Generalising to four sources is the same shape, more code, no new architectural concept.
|
||||
|
||||
### Q2 — Filter grammar shape
|
||||
|
||||
**Recommendation: structured JSON spec, validated server-side, exposed to the UI as predicates.**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["deadline", "appointment", "project_event", "approval_request"],
|
||||
|
||||
"scope": {
|
||||
"projects": "all_visible",
|
||||
"personal_only": false
|
||||
},
|
||||
|
||||
"time": {
|
||||
"horizon": "next_30d",
|
||||
"field": "auto"
|
||||
},
|
||||
|
||||
"predicates": {
|
||||
"deadline": {
|
||||
"status": ["pending"],
|
||||
"approval_status": ["approved", "pending", "legacy"],
|
||||
"event_types": [],
|
||||
"include_untyped": true
|
||||
},
|
||||
"appointment": {
|
||||
"approval_status": ["approved", "pending", "legacy"],
|
||||
"appointment_types": []
|
||||
},
|
||||
"project_event": {
|
||||
"event_types": [
|
||||
"project_created", "status_changed", "project_archived",
|
||||
"deadline_created", "appointment_created", "approval_decided"
|
||||
]
|
||||
},
|
||||
"approval_request": {
|
||||
"viewer_role": "approver_eligible",
|
||||
"status": ["pending"],
|
||||
"entity_types": ["deadline", "appointment"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The shape:
|
||||
|
||||
- **`sources`** — one or more `DataSource` values. Drives which per-source SQL paths run.
|
||||
- **`scope.projects`** — `"all_visible"` (default — RLS-bounded) | `"my_subtree"` (semantic: caller's direct/derived staffing tree) | `[<uuid>...]` (explicit list, RLS still applies).
|
||||
- **`scope.personal_only`** — narrows deadline + appointment to caller-created rows; ignored for project_event + approval_request (where actor scoping is already implicit).
|
||||
- **`time.horizon`** — `"any"` | `"next_7d"` | `"next_30d"` | `"next_90d"` | `"past_30d"` | `"past_90d"` | `"all"` | `{from, to}` literal range. `"auto"` for the date field means each source picks: deadline → `due_date`, appointment → `start_at`, project_event → `created_at`, approval_request → `requested_at` (or `decided_at` if status is decided).
|
||||
- **`predicates.<source>`** — per-source narrowing (status, types, eligibility). Empty / missing = no narrowing.
|
||||
|
||||
Validation lives in Go: a `ValidateFilterSpec(spec)` function rejects unknown fields, unknown enum values, conflicting combos (`personal_only=true` + explicit `projects` list → error). The UI never sends raw user-typed JSON; it composes the spec from widget state. A "Show JSON" reveal is available in the editor for power users — but the same validator runs on POST.
|
||||
|
||||
Three options considered:
|
||||
|
||||
| Option | Power | Risk | Verdict |
|
||||
|---|---|---|---|
|
||||
| **JSON predicate spec (recommended)** | High — every dimension addressable | Schema drift → validator bug | ✅ |
|
||||
| SQL-fragment DSL (`WHERE status='pending' AND …`) | Highest | Injection, RLS-bypass risk; needs a parser | ✗ |
|
||||
| UI-only, no spec language | Lowest | Every new dimension = UI work + DB migration | ✗ |
|
||||
|
||||
### Q3 — Granularity dimensions
|
||||
|
||||
m's brainstorm called out: my projects / specific projects / newly added cases / newly added events / changes to events / approved-vs-unapproved / time horizon / event type / role-perspective.
|
||||
|
||||
The full dimension set, mapped to the spec:
|
||||
|
||||
| Dimension | Where it lives in `FilterSpec` | UI affordance | Notes |
|
||||
|---|---|---|---|
|
||||
| My projects | `scope.projects = "my_subtree"` | toggle | semantic, resolved at query time via t-139 derivation predicate |
|
||||
| Specific projects | `scope.projects = [...]` | multi-select | RLS still applies; rows from inaccessible projects are silently filtered (Q17) |
|
||||
| Personal-only | `scope.personal_only = true` | toggle | mutually exclusive with `projects` (server enforces) |
|
||||
| Newly added cases | `sources: ["project_event"]` + `predicates.project_event.event_types: ["project_created"]` + `time.horizon` | source toggle + event-type chip group | same shape captures status_changed, project_archived |
|
||||
| Newly added events | `sources: ["deadline","appointment"]` + `time.horizon` + `time.field = "created_at"` | source toggles + time-field selector | the `created_at` rather than `due_date`/`start_at` view |
|
||||
| Changes to events | `sources: ["project_event"]` + `predicates.project_event.event_types: ["deadline_*","appointment_*"]` | event-type chips | project_events already audit deadline + appointment lifecycle (verified via existing emit sites) |
|
||||
| Approval status of entities | `predicates.deadline.approval_status` + `predicates.appointment.approval_status` | tri-state chip | reflects the entity-side `approval_status` column |
|
||||
| Approval lifecycle (the requests themselves) | `sources: ["approval_request"]` + `predicates.approval_request.status` + `predicates.approval_request.viewer_role` | source toggle + role chip | Q13 — the inbox shape |
|
||||
| Time horizon | `time.horizon` + optional `{from, to}` | range chips + date pickers | shared across all sources |
|
||||
| Event type (deadline) | `predicates.deadline.event_types` | multi-select | reuses existing `paliad.event_types` registry |
|
||||
| Appointment type | `predicates.appointment.appointment_types` | multi-select | hearing/meeting/consultation/deadline_hearing |
|
||||
| Project event kind | `predicates.project_event.event_types` | multi-select | free-text today; we'll need a curated list (§10 Q19) |
|
||||
| Role-perspective | implicit — every query is "from caller's viewpoint" | n/a | not a filter; visibility predicate is the user identity |
|
||||
|
||||
Hidden defaults vs UI affordances:
|
||||
|
||||
- **Hidden** — `version`, `time.field` (`"auto"` is the default), per-source `include_untyped`, validator branches.
|
||||
- **First-class UI** — sources, scope, time horizon, status, event_type/appointment_type/project_event_kind, approval status.
|
||||
- **Power-only** (revealed in JSON editor) — explicit `{from, to}` ranges beyond the chip set, `time.field` override.
|
||||
|
||||
### Q13 — Approval candidates: predicate or source?
|
||||
|
||||
**Recommendation: source (`approval_request`).**
|
||||
|
||||
Reasoning: the approval_requests table has fundamentally different columns (`lifecycle_event`, `pre_image`, `payload`, `requested_by`, `decision_kind`, `decided_at`) than deadline/appointment, and the inbox UI renders different things (requester avatar, "Approve / Reject" buttons, decision history). Forcing this into a predicate on deadline/appointment rows means either:
|
||||
|
||||
- (a) hiding the request rows entirely — but then "show me pending approvals" is impossible to express, or
|
||||
- (b) hydrating every deadline row with its pending-request payload — bloats the row shape, kills the "approval_status pill" abstraction.
|
||||
|
||||
By making it a source:
|
||||
|
||||
- `sources: ["approval_request"]` is the *inbox shape* — list of pending requests, decided requests, etc.
|
||||
- `predicates.deadline.approval_status: ["pending"]` is the *entity shape* — list of deadlines that have a pending request (good for "show me my deadlines that are blocked on someone else's approval").
|
||||
|
||||
These are genuinely two views; the substrate exposes both.
|
||||
|
||||
---
|
||||
|
||||
## 4. Section B — Render shapes + view authoring UX (Q4–Q6, Q11–Q12, Q16)
|
||||
|
||||
### Q4 — Which render shapes are first-class for v1?
|
||||
|
||||
**Recommendation: `list`, `cards`, `calendar` — three shapes.**
|
||||
|
||||
m's correction (2026-05-07): activity is a content selection (sources + filters), not a render shape. The "compact one-line stream with type icons" appearance is `list` shape with `density: "compact"` + an actor/time column set — same component, different config. Shape is orthogonal to source: any source can render in any shape.
|
||||
|
||||
| Shape | Status today | What it does | Source bias |
|
||||
|---|---|---|---|
|
||||
| **`list`** | shipped on `/events` (table), `/inbox` (`<ul class="inbox-list">`), `/dashboard` activity feed | One row per result; columns vary per source. Table for desktop, stacked card-rows on mobile. Density modes: `comfortable` (default, full table) / `compact` (one-line stream — the activity-feed look). | source-agnostic |
|
||||
| **`cards`** | shipped on `/agenda` (day-grouped timeline) | Day-grouped chronological cards; primary date drives grouping. The unified-inbox-feel m described — *when fed activity-style content*. | source-agnostic |
|
||||
| **`calendar`** | shipped on `/events?view=calendar` | Month grid (toggleable to week). Shows up to N pills per day. Click → popup with the day's rows. | works best for time-bound sources (deadline, appointment, project_event) |
|
||||
|
||||
How "activity feed" is expressed in this model:
|
||||
- **Filter side**: `sources: ["project_event", "approval_request"]`, `time.horizon: past_30d`, `time.field: created_at`.
|
||||
- **Render side**: `shape: "list"`, `list.density: "compact"`, `list.columns: ["time", "actor", "title", "project"]`.
|
||||
|
||||
That same `list` shape — with `density: "comfortable"` + the deadline column set — also powers `/events`. One component, two configs. Same logic for `cards`: the day-grouped Verlauf on `/projects/{id}` and a "newest cases this week" card view share the component.
|
||||
|
||||
Defer to v2: `kanban` (no obvious column axis across mixed sources), `connections-graph` (the events↔files visualisation referenced in the issue body — that's specifically about graph rendering, which is a 5x bigger component and works better as its own page than as a saved-view shape), `timeline-distinct-from-cards` (a horizontal Gantt would be the natural shape but adds a lot for marginal value at v1).
|
||||
|
||||
Why these three and not all six: each shape is a real frontend component with empty states, error states, layout, density toggles, mobile behaviour. We have three already shipped today, generalising them costs little. Adding `kanban` + `graph` is each its own component-week. Better to ship 3 polished than 6 half-baked.
|
||||
|
||||
### Q5 — Per-shape config
|
||||
|
||||
**Recommendation: shape config lives alongside filter spec in `render_spec`, keyed by shape.**
|
||||
|
||||
```json
|
||||
{
|
||||
"shape": "list",
|
||||
"list": { "columns": ["date", "title", "project", "status"], "sort": "date_asc", "density": "comfortable" },
|
||||
"cards": { "group_by": "day", "sort": "date_asc", "show_empty_days": false },
|
||||
"calendar": { "default_view": "month", "show_weekends": true }
|
||||
}
|
||||
```
|
||||
|
||||
The user picks one `shape`; the matching config block is read at render time. Other shape configs are kept (so flipping back to a previously-used shape preserves its tweaks).
|
||||
|
||||
UI: the shape switcher is a **3-button row** at the top of every view page. Right of it, a small "Shape settings" gear opens a modal with the per-shape knobs. Most users never touch the gear.
|
||||
|
||||
Default values per shape:
|
||||
|
||||
- `list.columns` = source-determined (deadline view = date/title/rule/status; appointment view = date/title/location/type; activity-feel view = time/actor/title — auto-selected when sources are activity-flavoured)
|
||||
- `list.density` = `"comfortable"` for entity sources, `"compact"` when sources include project_event or approval_request
|
||||
- `list.sort` = `"date_asc"` for forward-looking views, `"date_desc"` for retrospective
|
||||
- `cards.group_by` = `"day"`
|
||||
- `calendar.default_view` = `"month"`
|
||||
|
||||
### Q6 — Empty state per view
|
||||
|
||||
**Recommendation: filter-aware empty states. Render component receives the resolved `FilterSpec` and produces a guidance line.**
|
||||
|
||||
Generic shape:
|
||||
|
||||
> **Keine Einträge gefunden.**
|
||||
> Sicht: *{view name}* — {N} Filter aktiv (*Zeitraum: nächste 7 Tage, Status: offen*).
|
||||
> Vorschläge: [Zeitraum erweitern] [Filter zurücksetzen]
|
||||
|
||||
The component derives the human-readable filter summary from the spec. For specific known patterns:
|
||||
|
||||
- All-empty across sources + horizon `next_7d` → "Nichts in den nächsten 7 Tagen — versuchen Sie 30 Tage."
|
||||
- Sources picked but all 0 in 90d → "Keine Daten für diese Quellen — Sicht eventuell zu eng."
|
||||
- Project filter set but project has no team → already handled at API layer (Q17).
|
||||
|
||||
Empty-state strings live in i18n; the view name + filter summary are interpolated at render time.
|
||||
|
||||
### Q11 — Where do you create a view?
|
||||
|
||||
**Recommendation: both, with the inline path as primary.**
|
||||
|
||||
Two creation paths:
|
||||
|
||||
1. **Inline "save current filters as a Sicht"** (primary) — on any view page (system or existing custom), once the user has tweaked the filter spec away from the saved baseline, a "Speichern als Sicht" button appears in the toolbar. Click → modal asks for name + icon + sidebar position + render shape (defaults to current). Save → POST `/api/user-views` → sidebar refreshes → user is now on the new view. The same modal on an existing custom view shows a "Save changes / Save as new" pair.
|
||||
|
||||
2. **Full editor at `/views/new`** (secondary) — for the power case where the user wants to build a Sicht from a blank slate. Same modal fields, plus a JSON view of the filter spec for power users. Edit existing at `/views/{slug}/edit`.
|
||||
|
||||
Why both:
|
||||
|
||||
- The inline path covers the 90% case ("I tweaked the inbox to show only my projects, save it") with one click.
|
||||
- The full editor covers the 10% case where the user knows what they want but isn't currently looking at the right starting point ("I want a view of all approval-decided rows in the last 90 days").
|
||||
|
||||
Critically, **the inline path teaches the full editor** — both render the same form component.
|
||||
|
||||
### Q12 — Default-first onboarding
|
||||
|
||||
**Recommendation: empty + tutorial card on the first visit. No seeded examples.**
|
||||
|
||||
When a user with zero saved views clicks "Meine Sichten" or visits `/views`, they see:
|
||||
|
||||
> **Eigene Sichten — was ist das?**
|
||||
> Eine Sicht ist eine gespeicherte Filterkombination — z.B. "Fristen meiner Projekte in den nächsten 14 Tagen". Sichten erscheinen als eigene Buttons in der Sidebar.
|
||||
> [Beispiel-Sicht erstellen ▶] [Aus aktueller Seite speichern ▶]
|
||||
|
||||
The first button drops the user into the editor pre-populated with a sensible starter (e.g. "Activity feed for my subtree, last 30 days"). The second is contextual — only appears if the user has been on a system page recently (tracked client-side).
|
||||
|
||||
Why no seeded rows: seeded examples become orphan-confusion later ("did I make this Freitag-Stand thing? when?"). A dismissible tutorial card is cheaper to maintain and clearer about ownership.
|
||||
|
||||
### Q16 — URL contract
|
||||
|
||||
**Recommendation: `/views/{slug}` for custom views, slug user-scoped. System views keep their existing URLs.**
|
||||
|
||||
- **`/views/{slug}`** — slug is unique per `(user_id, slug)`. Slug is friendly: `freitag-stand`, `approvals-pending-mine`, `siemens-aktivitaet`. No UUIDs in URLs.
|
||||
- **`/views/new`** — creation editor.
|
||||
- **`/views/{slug}/edit`** — edit existing.
|
||||
|
||||
Filter overrides via query params:
|
||||
|
||||
- `/views/freitag-stand?from=2026-05-10&to=2026-05-17` — overrides the saved time horizon for this load only. Doesn't mutate the stored spec.
|
||||
- `/views/freitag-stand?shape=calendar` — overrides the saved render shape for this load only.
|
||||
|
||||
Override params follow the same validator as the stored spec; unknown params are ignored.
|
||||
|
||||
System views — `/dashboard`, `/agenda`, `/events`, `/inbox` — keep their URLs. They never become `/views/dashboard` (a slug collision the validator must reject — slug `dashboard` is reserved).
|
||||
|
||||
---
|
||||
|
||||
## 5. Section C — Persistence + sidebar + system-vs-user-view shape (Q7–Q10, Q14, Q15, Q17, Q18)
|
||||
|
||||
### Q7 — Schema for `paliad.user_views`
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.user_views (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Stable user-facing identifier. Goes into the URL. Validated:
|
||||
-- ^[a-z0-9][a-z0-9-]{0,62}$ with reserved-list rejection (dashboard,
|
||||
-- agenda, events, inbox, new, edit, …).
|
||||
slug text NOT NULL,
|
||||
|
||||
-- Display name. Free-form; no enforced i18n (the user picks the language
|
||||
-- they think in). Sidebar renders it verbatim; no fallback or translation.
|
||||
name text NOT NULL,
|
||||
|
||||
-- One of a fixed set of icon keys (see frontend/src/components/Sidebar.tsx
|
||||
-- icon registry). NULL → default icon (folder).
|
||||
icon text,
|
||||
|
||||
-- Filter spec (§3 Q2). Validated on write.
|
||||
filter_spec jsonb NOT NULL,
|
||||
|
||||
-- Render spec (§4 Q5). Validated on write.
|
||||
render_spec jsonb NOT NULL,
|
||||
|
||||
-- Sidebar ordering. Lower-first. Server defaults to MAX+1 on insert so
|
||||
-- new views land at the bottom; the editor lets the user drag-reorder.
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
|
||||
-- Show a row-count badge on the sidebar entry (like /inbox today).
|
||||
-- Costs one COUNT(*) per saved view per badge refresh; opt-in.
|
||||
show_count boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- "Most-recently-used" landing (Q10). PATCH on every view-load (cheap).
|
||||
last_used_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX user_views_owner_idx
|
||||
ON paliad.user_views (user_id, sort_order);
|
||||
|
||||
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY user_views_owner_all
|
||||
ON paliad.user_views FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- updated_at autoset trigger reusing existing paliad.set_updated_at().
|
||||
CREATE TRIGGER user_views_updated_at
|
||||
BEFORE UPDATE ON paliad.user_views
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();
|
||||
```
|
||||
|
||||
Notes on the shape:
|
||||
|
||||
- **No `is_system` flag** — system views are code-resident (Q8), not seeded rows. Keeps the table strictly user-owned.
|
||||
- **`filter_spec`/`render_spec` as `jsonb`** — Postgres validates only structural well-formedness; the application layer (`ValidateFilterSpec` + `ValidateRenderSpec`) enforces semantic constraints at write time. Storing the parsed shapes as columns would force a schema migration per new dimension.
|
||||
- **No cross-user sharing column** — explicit `OUT OF SCOPE` per the issue body. If sharing lands later, add a separate `user_view_shares (view_id, target_user_id, can_edit)` table.
|
||||
- **Slug uniqueness scoped to user** — two users can both have a view called `freitag-stand`; URL is `/views/freitag-stand` and resolves against `auth.uid()`.
|
||||
|
||||
Migration shape: new file `056_user_views.up.sql`. Standalone — no dependencies on 055's schema beyond `paliad.users` (which is in 002). 056 can land before 055 lands on main if needed.
|
||||
|
||||
### Q8 — System views: code or DB?
|
||||
|
||||
**Recommendation: code-resident.** Defaults stay as their own pages; their handlers continue to render their existing TSX shells; their data path is the substrate.
|
||||
|
||||
```go
|
||||
// internal/services/system_views.go (new)
|
||||
|
||||
// SystemView is a code-resident view definition. Used by the substrate
|
||||
// when a system page (dashboard, agenda, events, inbox) needs to resolve
|
||||
// its data through the unified pipeline.
|
||||
type SystemView struct {
|
||||
Slug string // "dashboard" | "agenda" | "events" | "inbox" — matches URL
|
||||
Filter FilterSpec // canonical spec the page resolves to today
|
||||
Render RenderSpec // canonical render shape
|
||||
Reserved bool // if true, slug is unavailable for user views (true for all 4)
|
||||
}
|
||||
|
||||
func DashboardSystemView() SystemView { /* …multi-section, special-cased… */ }
|
||||
func AgendaSystemView() SystemView { /* sources: deadline+appointment, shape: cards, horizon: 30d */ }
|
||||
func EventsSystemView() SystemView { /* sources: deadline+appointment, shape: list, configurable */ }
|
||||
func InboxSystemView() SystemView { /* sources: approval_request, viewer_role: approver_eligible, shape: list */ }
|
||||
```
|
||||
|
||||
Tradeoff (config-as-code vs config-as-data):
|
||||
|
||||
| Axis | Code (recommended) | DB seed |
|
||||
|---|---|---|
|
||||
| Ships with releases | ✅ atomic with code | ✗ requires per-user backfill |
|
||||
| New users get latest | ✅ always | ✗ depends on seed timing |
|
||||
| User-editable | ✗ — system views deliberately frozen | ✅ — but then "system" is meaningless |
|
||||
| Drift risk | none | high (schema bump → seeded rows go stale) |
|
||||
| Validator complexity | one path | two paths (code path + seed path) |
|
||||
|
||||
The locked direction is "additive — fixed defaults stay alongside Custom Views". I read that as: defaults are *not* user-editable; the user can build a custom view that mimics a default if they want a tweaked version. Config-as-code matches that intent exactly.
|
||||
|
||||
Dashboard is the awkward one — it's not a single saved view, it's a multi-section page (5-bucket summary + matter card + 2-column lists + activity feed). The recommendation is: keep `/dashboard` as a bespoke page composed of *several* internal queries, each of which can resolve to a `SystemView` later. Don't try to express the dashboard as one SystemView; that's the wrong abstraction.
|
||||
|
||||
### Q9 — Sidebar layout
|
||||
|
||||
**Recommendation:** new "Meine Sichten" group between "Arbeit" and "Werkzeuge".
|
||||
|
||||
```
|
||||
Übersicht:
|
||||
Dashboard
|
||||
Agenda
|
||||
Inbox [3]
|
||||
Team
|
||||
|
||||
Arbeit:
|
||||
Projekte
|
||||
Fristen
|
||||
Termine
|
||||
|
||||
Meine Sichten: ← new group
|
||||
Freitag-Stand [12]
|
||||
Approval-Pending-Mine
|
||||
Siemens-Aktivität
|
||||
+ Neue Sicht ← always-last entry
|
||||
|
||||
Werkzeuge: …
|
||||
Wissen: …
|
||||
Ressourcen: …
|
||||
Einstellungen: …
|
||||
Admin: …
|
||||
```
|
||||
|
||||
Layout decisions:
|
||||
|
||||
- **Position**: between Arbeit and Werkzeuge — close to the work flow, before the tools/knowledge sections. m's brainstorm placed it as "a separate button" but didn't pin top vs bottom; this position keeps it in the work-context band.
|
||||
- **Group label**: "Meine Sichten" / "My Views" — i18n key `nav.group.user_views`.
|
||||
- **Empty group**: if the user has zero saved views, the group still renders, with only the "+ Neue Sicht" entry inside. That makes the feature discoverable; the alternative (hide empty group) buries it.
|
||||
- **Per-entry icon**: from a fixed registry of ~20 icons (folder, calendar, clock, bell, files, users, …) reused from the existing sidebar SVG set. Default = folder.
|
||||
- **Per-entry badge**: shown when `show_count=true` on the saved view. Server returns the count via `/api/user-views?include_count=true`; the same client refresh interval as `/api/inbox/count` (~60s). Badge is the count of currently-matching rows — same shape as the inbox bell today.
|
||||
- **Drag-reorder**: the editor lets users drag entries; click-to-edit on hover.
|
||||
- **Mobile**: the bottom-nav shows fixed entries only (Übersicht items) — saved views are accessible via the burger drawer. Otherwise the bottom-nav fills up the moment a power user has 5 saved views.
|
||||
|
||||
### Q10 — Default landing
|
||||
|
||||
**Recommendation: most-recently-used.**
|
||||
|
||||
When the user clicks "Meine Sichten" (the group label, not a specific entry), they navigate to `/views`, which resolves to:
|
||||
|
||||
- If `last_used_at` is set on any view → 302 to that view's URL.
|
||||
- If no view has `last_used_at` → render the onboarding card (Q12).
|
||||
|
||||
`last_used_at` is updated on every view-load via a fire-and-forget PATCH `/api/user-views/{id}/touch`. Cheap; no UI latency.
|
||||
|
||||
Alternative (always-default to first by sort_order) was considered — feels less helpful (the user sorted by what they want to see *most easily*, but might not be visiting *most often*). Most-recently-used reflects actual workflow.
|
||||
|
||||
### Q14 — `/inbox` page
|
||||
|
||||
**Recommendation: stays as a fixed sidebar entry. Internally refactored to use the substrate.**
|
||||
|
||||
Three paths considered:
|
||||
|
||||
| Path | Pros | Cons |
|
||||
|---|---|---|
|
||||
| Keep `/inbox` as today, no internal change | zero migration risk | duplicate read path; "subsume" goal not met |
|
||||
| **Refactor `/inbox` to use the substrate (recommended)** | one read path; future enhancements lift everyone | small migration effort |
|
||||
| Retire `/inbox`, ship as a Custom View | cleanest concept | breaks every email link; users with the URL bookmarked get 404 |
|
||||
|
||||
The recommendation refactors `/inbox` internally but keeps the URL + sidebar entry. Concretely:
|
||||
|
||||
- The two-tab UI ("Zur Genehmigung" / "Meine Anfragen") on `/inbox` becomes two `SystemView` definitions:
|
||||
- `InboxApproverView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "approver_eligible", status: ["pending"]}`, `render.shape: "list"`.
|
||||
- `InboxRequesterView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "self_requested"}`, `render.shape: "list"`.
|
||||
- The `/inbox` handler resolves to one of these depending on the active tab; the data path goes through `ViewService.Run(ctx, userID, spec)`.
|
||||
- The frontend keeps the existing two-tab UI; the per-row card markup also stays (the substrate's `list` shape with `kind="approval_request"` knows how to render approval rows including approve/reject buttons).
|
||||
- The `nav.inbox` sidebar entry stays; the bell badge keeps reading from `ApprovalService.PendingCountForUser`.
|
||||
|
||||
This satisfies the "subsume the unified-inbox concept" goal: any user can build a Custom View that picks `approval_request` as one source plus `project_event` as another, and gets the unified-inbox feel m's brainstorm described — without losing the dedicated `/inbox` shortcut.
|
||||
|
||||
### Q15 — Existing fixed pages: reroute or stay independent?
|
||||
|
||||
**Recommendation: phased.** Phase A (this design's implementation) leaves system pages independent; Phase B (separate later task) refactors them to use the substrate.
|
||||
|
||||
| Phase | Scope | Risk | Locked direction fit |
|
||||
|---|---|---|---|
|
||||
| **A — substrate + Custom Views ship; defaults untouched** | new code: ViewService, FilterSpec, RenderSpec, view_service handlers, /views/* pages, paliad.user_views | low — additive | exactly matches m's "additive" framing |
|
||||
| **B — refactor /agenda, /events, /dashboard, /inbox internals to use ViewService** | rip out parallel read paths; defaults become SystemView-resolved | medium — touches every default page | optional; ship when A is stable |
|
||||
|
||||
Why phase A is enough on its own to ship value: the user gets Custom Views, the unified-inbox-shape becomes available, every system page keeps working untouched. Phase B is a clean-up — eliminating duplicate read paths — and can wait until A's substrate is exercised.
|
||||
|
||||
If we tried to do A+B in one shot, the PR would be:
|
||||
|
||||
- 1× new substrate (~1500 LoC across services + handlers + frontend)
|
||||
- 4× system page refactors (~800 LoC each = ~3200 LoC)
|
||||
- = ~4700 LoC, 4 surfaces moving simultaneously
|
||||
|
||||
That's a 2-week change and a much higher rollback-cost. Phasing means A is shippable in ~1500 LoC and B can be tackled per-page later.
|
||||
|
||||
### Q17 — Auth + RLS + lost project access
|
||||
|
||||
**Recommendation: fail open with attribution.**
|
||||
|
||||
Behaviour:
|
||||
|
||||
- A saved view's `filter_spec.scope.projects` may include UUIDs the user no longer has team access to.
|
||||
- The substrate query JOINs through `paliad.projects p` with the visibility predicate (`paliad.can_see_project(p.id)` per t-139). RLS naturally hides rows from inaccessible projects.
|
||||
- The view loads. The user sees the rows they *can* see; the inaccessible ones are absent.
|
||||
- A one-time toast surfaces: "1 Projekt in dieser Sicht ist nicht mehr sichtbar" (count derived server-side: requested-IDs minus visible-IDs).
|
||||
- The toast offers a "Sicht bearbeiten" link → opens the editor with the inaccessible IDs prefilled in a "Entfernen?" section.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Fail closed (whole view 403) | Too aggressive — a 50-project view shouldn't black out because 1 was archived. |
|
||||
| Silently drop with no surface | Confuses the user; "why is my view empty today?" |
|
||||
| Auto-prune on first load | Mutates stored data without consent. |
|
||||
|
||||
Failing open + attributing matches the "transparent honesty" principle from t-139 (derived membership annotated, not silent).
|
||||
|
||||
### Q18 — Materialisation & performance
|
||||
|
||||
**Recommendation: no materialisation v1. Cursor pagination + per-source row caps.**
|
||||
|
||||
Performance shape:
|
||||
|
||||
- **Substrate runs on every load.** Each source contributes one SQL path; merge happens in Go (small per-page result set). No precomputation.
|
||||
- **Pagination** is cursor-based: `(event_date DESC, id DESC)` for retrospective views, `(event_date ASC, id ASC)` for forward-looking. Cursor = base64-encoded `{date, id}`. Default page size 100; cap 200.
|
||||
- **Time horizon is mandatory.** Default is `next_30d` for forward-looking views, `past_30d` for retrospective. The validator rejects `time.horizon = "all"` *unless* `scope.projects` is set to a non-empty explicit list (capping the row pool).
|
||||
- **Per-source LIMIT** inside each SQL path (default 500; configurable per-source). Caps the worst case where one source dominates the union.
|
||||
|
||||
What this looks like for the worst case the issue body raised — "all events from all my projects in the next 90 days, sorted by due_date":
|
||||
|
||||
- 50 projects × thousands of rows each = ~150k rows, theoretical. In practice, paliad data today has dozens-to-low-hundreds per project; even at 50 projects, the *date-bounded* result is in the hundreds-low-thousands range.
|
||||
- Each per-source query has the visibility predicate (RLS is via `EXISTS` against `project_teams` + path-walk) — t-124 confirmed this scales with depth, not row count.
|
||||
- Even at 5k merged rows, in-memory sort + 100-row paginated slice is a few ms.
|
||||
|
||||
We add materialisation only if telemetry says we need to. Concretely: a request-duration histogram on `/api/views/{slug}/run` with p99 alarm at 1s. If p99 climbs past 500ms, we add per-source materialised rollups (e.g. `mv_user_view_counts_daily`) and short-circuit summary cards through them.
|
||||
|
||||
The substrate's `count` endpoint (used by the sidebar badge for `show_count=true` views) is a lighter shape — it returns one integer per source. That can hit a lighter path (no JOINs to projects beyond the RLS predicate). If a user has 10 saved views with `show_count=true` × 60s refresh = 10 COUNT(*) queries per minute per logged-in user. That's the first scale wall and is the candidate for caching in Phase B.
|
||||
|
||||
---
|
||||
|
||||
## 6. Section D — Cross-cutting concerns
|
||||
|
||||
### 6.1 Coexistence with t-139 (hierarchy aggregation, in flight)
|
||||
|
||||
t-139 adds `paliad.project_partner_units` + `derive_grants_authority` + an extended `can_see_project()` predicate. The substrate uses `can_see_project()` (or equivalent positional helpers like `visibilityPredicate("p")` already does) — so derived membership transparently widens what shows up in saved views, just like it widens what shows up on `/agenda` today.
|
||||
|
||||
**No coordination commit required.** If t-139 lands first, this design's substrate inherits derivation for free. If this design lands first (unlikely given the merge order), the substrate works against the pre-139 visibility predicate; t-139's later landing widens results without code change here.
|
||||
|
||||
The `scope.projects = "my_subtree"` semantic resolves through `DerivationService.EffectiveProjectRole` (added by t-139 Phase 2). Until t-139 lands, "my_subtree" falls back to "direct + descendant" (via `projectDescendantPredicate` from t-124). The frontend chip label stays the same; only the resolved set widens.
|
||||
|
||||
### 6.2 Coexistence with t-138 (approvals, shipped)
|
||||
|
||||
t-138 added `paliad.approval_requests` + `entity.approval_status` + the inbox SQL. The substrate uses `approval_requests` as `data_source = "approval_request"` directly — same RLS, same JOIN against `paliad.users` for requester/decider names. The substrate's approval-side filter `predicates.approval_request.viewer_role = "approver_eligible"` resolves via `ApprovalService.ListPendingForApprover` (its existing SQL).
|
||||
|
||||
The entity-side pill (`approval_status='pending'`) on deadline/appointment rows in the substrate is unchanged — `EventListItem.ApprovalStatus` is already populated in `event_service.go`.
|
||||
|
||||
### 6.3 Existing `EventService` — extend or replace?
|
||||
|
||||
**Recommendation: extend.** Rename `EventService` → `ViewService` (or keep `EventService` as the type and add a `ListVisibleAsViewRows` method that returns `[]ViewRow` instead of `[]EventListItem`). The existing `ListVisibleForUser([]EventListItem, …)` callers (`/api/events`, `/api/events/summary`) keep working unchanged.
|
||||
|
||||
Two-source → four-source generalisation:
|
||||
|
||||
- Add `loadProjectEventRows(ctx, userID, spec)` → similar to `loadAppointments` shape, queries `paliad.project_events` JOIN `paliad.projects` with visibility predicate.
|
||||
- Add `loadApprovalRequestRows(ctx, userID, spec)` → wraps `ApprovalService.ListPendingForApprover` / `ListSubmittedByUser` and projects to `ViewRow`.
|
||||
- The merge step in `ListVisibleForUser` becomes "merge N source results sorted by event_date".
|
||||
|
||||
`AgendaService` is the second substrate today (timeline-shaped). Phase B can retire it (Agenda becomes a SystemView with `shape: "cards"`); Phase A leaves it untouched.
|
||||
|
||||
### 6.4 i18n
|
||||
|
||||
User-facing strings:
|
||||
|
||||
- "Meine Sichten" / "My Views" (sidebar group label)
|
||||
- "Neue Sicht" / "New View" (creation entry)
|
||||
- "Speichern als Sicht" / "Save as View"
|
||||
- "Sicht bearbeiten" / "Edit View"
|
||||
- shape labels: "Liste / List", "Karten / Cards", "Kalender / Calendar"
|
||||
- per-source labels: "Fristen / Deadlines", "Termine / Appointments", "Projekt-Verlauf / Project history", "Genehmigungen / Approvals"
|
||||
- empty-state composition strings (filter summary)
|
||||
- error toast for inaccessible-project case
|
||||
|
||||
Total estimate: ~80 new keys, DE + EN.
|
||||
|
||||
### 6.5 Bottom nav (mobile)
|
||||
|
||||
The bottom nav today shows 4 fixed entries (Übersicht-band). It does NOT extend with saved views — that would balloon to N+4 at every saved view. Saved views remain accessible via the sidebar drawer.
|
||||
|
||||
If telemetry shows mobile users routinely hitting saved views, consider a "Pin to bottom-nav" toggle on individual views (max 1 pinned view added between Übersicht and the burger).
|
||||
|
||||
---
|
||||
|
||||
## 7. Section E — Implementation phasing (PR shape)
|
||||
|
||||
### PR split decision (2026-05-07)
|
||||
|
||||
m delegated the split call to the inventor. Phase A is split into **two stacked PRs**:
|
||||
|
||||
- **A1 — Backend substrate + Custom Views API.** Migration 056, FilterSpec/RenderSpec types + validators, ViewService 4-source extension, UserViewService CRUD, SystemView registry, all `/api/*` endpoints, full backend test coverage. *No user-visible change.* Smoke-testable via curl. ~1800 LoC.
|
||||
- **A2 — Frontend Custom Views UI.** Generic view shell (`/views/{slug}`), view editor (`/views/new`, `/views/{slug}/edit`), 3 render-shape components (list/cards/calendar), sidebar "Meine Sichten" group, i18n, CSS. Builds on A1's API. ~1600 LoC.
|
||||
|
||||
Why split: A1 is mergeable + deployable in isolation (additive, no UI risk), exercises the validator surface, lets A2 build on a stable contract. A2 is purely additive once A1 lands. Each PR fits in a normal review window.
|
||||
|
||||
A1 → main → A2 → main is the merge order.
|
||||
|
||||
### Phase A — substrate + Custom Views (this task's locked scope)
|
||||
|
||||
| Step | Files | Approx. LoC | Notes |
|
||||
|---|---|---|---|
|
||||
| 1. Migration `056_user_views` | `internal/db/migrations/056_user_views.up.sql` (+ down) | 60 | table + indexes + RLS + trigger |
|
||||
| 2. Filter/Render spec types + validator | `internal/services/filter_spec.go`, `render_spec.go` | 350 | Go structs + JSON marshalling + `Validate*` |
|
||||
| 3. ViewService — extend EventService | `internal/services/view_service.go` (rename + extend) | 500 | add 2 source loaders; merge N sources |
|
||||
| 4. UserViewService — CRUD | `internal/services/user_view_service.go` | 300 | List/Get/Create/Update/Delete/Touch |
|
||||
| 5. SystemView registry | `internal/services/system_views.go` | 150 | 4 SystemView definitions + reserved-slug list |
|
||||
| 6. HTTP handlers | `internal/handlers/views.go` (new) + adjust `events.go`, `agenda.go`, `inbox.go` minimally | 400 | `/api/user-views/*`, `/api/views/{slug}/run`, `/views/*` page handlers |
|
||||
| 7. Frontend — generic view shell | `frontend/src/views.tsx` + `client/views.ts` | 500 | renders any FilterSpec + RenderSpec; powers `/views/*` |
|
||||
| 8. Frontend — render shape components | `frontend/src/views/{list,cards,calendar,activity}.ts` | 600 | shared by system + custom |
|
||||
| 9. Frontend — view editor | `frontend/src/views-editor.tsx` + client | 400 | inline-save modal + full editor |
|
||||
| 10. Sidebar — Meine Sichten group | `frontend/src/components/Sidebar.tsx` + sidebar.ts | 150 | render saved views from /api/user-views; badge refresh |
|
||||
| 11. i18n | `frontend/src/i18n.ts` | ~80 keys | DE + EN |
|
||||
| 12. Tests | `*_test.go` for spec validators + ViewService | 400 | spec round-trip, RLS, source merge ordering |
|
||||
| **Total** | | ~3400 | one PR |
|
||||
|
||||
Phase A ships standalone — no defaults are touched, no existing pages move.
|
||||
|
||||
### Phase B — refactor system pages onto substrate (separate task)
|
||||
|
||||
Per-page refactor: `/agenda` (substrate-shape `cards`), `/events` (substrate-shape `list`/`calendar`), `/inbox` (substrate-shape `list` + tab tied to viewer_role), `/dashboard` (composes multiple SystemViews into its sections). Each is its own PR. Total estimate: ~2000 LoC across all four. Ships any time after A is stable.
|
||||
|
||||
### Phase C — sharing + advanced shapes (future)
|
||||
|
||||
Cross-user sharing (`user_view_shares`), connections-graph render shape, kanban shape, real-time push updates. None of these are in scope for the current task; called out so the v1 spec doesn't paint us into a corner.
|
||||
|
||||
---
|
||||
|
||||
## 8. Section F — Worked examples
|
||||
|
||||
### 8.1 The unified-inbox m described
|
||||
|
||||
m's brainstorm: "approval candidates + project activity + new cases + status changes + everything that happened on my projects."
|
||||
|
||||
`FilterSpec`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["approval_request", "project_event", "deadline", "appointment"],
|
||||
"scope": { "projects": "my_subtree" },
|
||||
"time": { "horizon": "past_30d", "field": "auto" },
|
||||
"predicates": {
|
||||
"approval_request": { "viewer_role": "approver_eligible", "status": ["pending"] },
|
||||
"project_event": { "event_types": ["project_created", "status_changed", "deadline_created", "appointment_created", "approval_decided", "project_archived"] },
|
||||
"deadline": { "approval_status": ["approved","pending","legacy"], "status": ["pending"] },
|
||||
"appointment": { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RenderSpec`:
|
||||
|
||||
```json
|
||||
{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title", "project"], "sort": "date_desc" } }
|
||||
```
|
||||
|
||||
(The "activity-feed feel" comes from `density: "compact"` + the actor/time column set, not from a separate shape — m's correction 2026-05-07.)
|
||||
|
||||
User saves as `meine-aktivitaet`. URL: `/views/meine-aktivitaet`. Sidebar entry under "Meine Sichten" with the bell icon. show_count=true → badge shows count of pending approvals + new audit events in past 30d.
|
||||
|
||||
### 8.2 The "myVerySpecialAgenda"
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["deadline", "appointment"],
|
||||
"scope": { "projects": [<project-uuid-1>, <project-uuid-2>] },
|
||||
"time": { "horizon": "next_14d" },
|
||||
"predicates": {
|
||||
"deadline": { "status": ["pending"], "event_types": [<litigation-event-type-uuid>] },
|
||||
"appointment": { "appointment_types": ["hearing", "deadline_hearing"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RenderSpec`: `{ "shape": "calendar", "calendar": { "default_view": "week" } }`
|
||||
|
||||
### 8.3 "Was hat sich auf Siemens AG geändert?"
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sources": ["project_event"],
|
||||
"scope": { "projects": [<siemens-client-uuid>] },
|
||||
"time": { "horizon": "past_90d" },
|
||||
"predicates": { "project_event": { "event_types": ["status_changed", "project_reparented", "deadline_completed"] } }
|
||||
}
|
||||
```
|
||||
|
||||
`RenderSpec`: `{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title"], "sort": "date_desc" } }`
|
||||
|
||||
(`scope.projects` referencing a top-level Client UUID + the path-walk visibility predicate naturally pulls all descendants — this is exactly the t-139 aggregation, surfaced through the substrate.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Section G — Trade-offs flagged
|
||||
|
||||
1. **Substrate complexity vs default-page simplicity.** The substrate is meaningfully more complex than today's `EventService`. The win is that *every future "show me X across my work"* request maps to the same code path. Without it, every new viewpoint is a new bespoke handler — t-138's inbox is the most recent precedent (~900 LoC).
|
||||
2. **JSON spec discoverability.** Power users will appreciate the JSON-spec affordance; casual users may never see it. The risk is that the affordance attracts feature-creep ("can we just add a `like_pattern` predicate?"). Mitigation: `version: 1` field + strict validator + a "spec changes go through inventor" rule documented in `docs/`.
|
||||
3. **Storage cost of `paliad.user_views`.** Each saved view is ~2KB jsonb. 100 active users × 5 saved views = 1MB. Negligible.
|
||||
4. **Sidebar growth.** Heavy users may end up with 10+ saved views in the sidebar group. The drag-reorder editor is the relief valve; if pain emerges, add a "Collapse group" affordance.
|
||||
5. **`show_count` query load.** Each show_count=true view = 1 COUNT(*) per refresh. If users go count-happy, this becomes a real load. Mitigation: cap show_count=true to 5 per user; cache counts for 30s server-side.
|
||||
6. **System pages staying independent (Phase A).** Two read paths during the A→B window. Drift risk if the substrate gains behaviour the system pages miss. Mitigation: feature flag the new `/views/*` for power users until B is in flight.
|
||||
7. **Slug collisions with future system URLs.** Reserve a static list (`dashboard`, `agenda`, `events`, `inbox`, `new`, `edit`, `tools`, `admin`, `settings`, `login`, `logout`, `projects`, `team`, `courts`, `glossary`, `links`, `downloads`, `checklists`, `views`). Validator rejects on write. Future URLs added → migration script renames any user views that crash.
|
||||
8. **Mobile UX of in-page render-shape switcher.** Calendar shape on a phone is cramped. Mitigation: when viewport width < 600px, calendar shape auto-falls back to cards (with a notice). Same pattern as `/events` today.
|
||||
|
||||
---
|
||||
|
||||
## 10. Section H — Open questions for m
|
||||
|
||||
**Status: LOCKED 2026-05-07.** m signed off on all Q19–Q27 recommendations.
|
||||
|
||||
Inventor has made recommendations on every Q1–Q18 from the issue body. The questions below are points where m's call would specifically refine the design before coder shift starts. Numbered fresh (Q19+) so they don't collide with the issue body's numbering.
|
||||
|
||||
**Q19. Curated `project_event` event-type list.**
|
||||
The audit table today has free-text `event_type` strings (`project_created`, `status_changed`, `deadline_created`, `approval_decided`, …). The substrate's filter dropdown needs a curated list. Should I:
|
||||
- (a) ship a hardcoded list of ~12 known kinds (verified via grep on `insertProjectEvent` callsites), or
|
||||
- (b) ship a `paliad.project_event_kinds` registry table seeded with the same list, future-extensible by admins?
|
||||
|
||||
Recommendation: (a). Free-text `event_type` is a code-resident constant; new kinds appear when code emits them, so a registry table would just shadow the code.
|
||||
|
||||
**Q20. Sidebar group position.**
|
||||
I placed "Meine Sichten" between Arbeit and Werkzeuge. Three other reasonable positions:
|
||||
- top of the sidebar (above Übersicht — most-used-first)
|
||||
- inside Übersicht (mixed with Dashboard/Agenda — but blurs the system/user distinction)
|
||||
- between Übersicht and Arbeit (saved views are *overviews* by intent)
|
||||
|
||||
Pick one — the implementation is identical in all four placements.
|
||||
|
||||
**Q21. Bottom-nav inclusion.**
|
||||
Mobile bottom-nav today has 4 fixed entries. The recommendation is to **not** extend it with saved views (sidebar drawer fills the gap). Confirm or reject. If reject: should pinned views be a per-view setting (max 1 pinned), or auto-pin the most-recently-used?
|
||||
|
||||
**Q22. Show-count default.**
|
||||
Per-view `show_count` defaults to false (recommendation §5 Q7). Confirm — alternative is default true with an explicit opt-out. The cost of true-default is more COUNT(*) queries.
|
||||
|
||||
**Q23. Reserved slugs.**
|
||||
List of forbidden user-view slugs (§9 trade-off 7). Anything to add or remove?
|
||||
|
||||
**Q24. Phase A surface area in coder shift.**
|
||||
Phase A is ~3400 LoC. Confirm one PR is the right shape, or split into A1 (substrate + spec types + system view refactor of /events only) + A2 (Custom Views CRUD + sidebar + editor)?
|
||||
|
||||
**Q25. View deletion confirmation.**
|
||||
A user deleting a saved view: should I require a "type the view name to confirm" pattern (matching admin deletes elsewhere in paliad), or a single Yes/No modal?
|
||||
|
||||
**Q26. Time-horizon mandatory clamp.**
|
||||
The validator rejects `time.horizon = "all"` unless `scope.projects` is non-empty (perf safeguard, §5 Q18). Does this feel right, or should `"all"` always be allowed (and we trust the per-source LIMIT to bound things)?
|
||||
|
||||
**Q27. Render-spec live preview in editor.**
|
||||
The editor today (proposed) saves on submit. Should the editor render a *live preview* of the current spec (running the substrate against the in-progress filter) — useful but adds a query per keystroke? Default-debounced (500ms) or explicit "Vorschau" button?
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope (v1)
|
||||
|
||||
Per the issue body — quoted for traceability:
|
||||
|
||||
- Replacing the fixed pages (they stay; can be removed later if usage warrants).
|
||||
- Cross-user view sharing.
|
||||
- Public / read-only links to views.
|
||||
- Real-time push updates ("inbox row appears when someone files an approval").
|
||||
- Cross-project rollups (rolling rows across unrelated projects).
|
||||
- Themes / per-view colour palettes.
|
||||
|
||||
Adding from inventor analysis:
|
||||
|
||||
- Connections-graph render shape (deferred per §4 Q4 — its own page later).
|
||||
- Kanban shape (no obvious column axis across mixed sources).
|
||||
- "Pin to bottom-nav" mobile affordance.
|
||||
- Materialised view/cache layer (deferred per §5 Q18 — telemetry-driven).
|
||||
|
||||
---
|
||||
|
||||
## 12. Files the implementer will touch (Phase A)
|
||||
|
||||
Backend:
|
||||
- `internal/db/migrations/056_user_views.up.sql` + `.down.sql` (new)
|
||||
- `internal/services/filter_spec.go` (new) — types + validator
|
||||
- `internal/services/render_spec.go` (new) — types + validator
|
||||
- `internal/services/view_service.go` (new — extends/renames `event_service.go`)
|
||||
- `internal/services/user_view_service.go` (new) — CRUD
|
||||
- `internal/services/system_views.go` (new) — 4 SystemView definitions
|
||||
- `internal/services/event_service.go` — update callers (or alias for back-compat)
|
||||
- `internal/handlers/views.go` (new) — `/api/user-views/*`, `/api/views/{slug}/run`, page handlers for `/views/*`
|
||||
- `internal/handlers/handlers.go` — wire the new routes
|
||||
- `internal/handlers/inbox.go` (light touch) — refactor read path to `ViewService` (Phase B candidate; can stay independent in Phase A if we want to minimize blast radius)
|
||||
|
||||
Frontend:
|
||||
- `frontend/src/views.tsx` (new) — generic view shell (`/views/{slug}` and `/views`)
|
||||
- `frontend/src/views-editor.tsx` (new) — full editor at `/views/new`, `/views/{slug}/edit`
|
||||
- `frontend/src/client/views/list.ts`, `cards.ts`, `calendar.ts`, `activity.ts` (new) — render shape components
|
||||
- `frontend/src/client/views.ts` (new) — view shell glue + shape switcher
|
||||
- `frontend/src/client/views-editor.ts` (new) — editor logic
|
||||
- `frontend/src/components/Sidebar.tsx` — add Meine Sichten group + render from `window.__PALIAD_USER_VIEWS__`
|
||||
- `frontend/src/client/sidebar.ts` — fetch/cache user views; badge refresh
|
||||
- `frontend/src/i18n.ts` — ~80 new keys DE+EN
|
||||
- `frontend/src/styles/global.css` — view-shell + render-shape switcher styles
|
||||
|
||||
Tests:
|
||||
- `internal/services/filter_spec_test.go` — validator (happy + edge cases + reject paths)
|
||||
- `internal/services/render_spec_test.go` — same
|
||||
- `internal/services/view_service_test.go` — 4-source merge ordering, RLS bounded
|
||||
- `internal/services/user_view_service_test.go` — CRUD + RLS
|
||||
- `frontend/src/client/views/*.test.ts` (if frontend testing infra exists; otherwise smoke via Playwright)
|
||||
|
||||
Build infra: none — uses existing `golang-migrate` + Bun pipelines.
|
||||
|
||||
---
|
||||
|
||||
## 13. Inventor stays parked
|
||||
|
||||
This design needs m's go on §10 (Q19–Q27) before coder shift starts. After m's call, the head routes the implementer (recommendation: noether or fresh coder; Phase A is mechanical-substantial but pattern-fluent — t-139's hierarchy substrate is the closest precedent in the codebase).
|
||||
|
||||
NOT cronus per m's directive (2026-05-06: cronus retired from paliad).
|
||||
|
||||
`mai report completed "DESIGN READY FOR REVIEW: data display model — additive Custom Views + 4-source substrate + 4 render shapes + paliad.user_views. 27 questions answered (18 from issue body + 9 follow-ups in §10). Awaiting m's go/no-go before coder shift."`
|
||||
947
docs/design-deadline-data-model-2026-05-08.md
Normal file
947
docs/design-deadline-data-model-2026-05-08.md
Normal file
@@ -0,0 +1,947 @@
|
||||
# Deadline Data Model — Proceedings-as-DAG
|
||||
|
||||
**Author:** einstein (consultant)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-158 ([Consultant] Deadline data model — proceedings-as-DAG analysis + recommendation)
|
||||
**Branch:** `mai/einstein/consultant-deadline-data`
|
||||
**Status:** DESIGN — analysis only, no schema changes in this branch.
|
||||
**Predecessors read:** docs/audit-fristenrechner-completeness-2026-04-30.md (curie), docs/plans/unified-fristenrechner.md + docs/plans/unified-fristenrechner-v3.md (cronus, archived author), docs/design-courts-per-country-holidays-2026-05-05.md (cronus, on-hold).
|
||||
**Companion:** feynman is in flight on `mai/feynman/fristenrechner` (t-paliad-157). Read that branch's WIP if pushed; do not take dependencies on it. This analysis is upstream of any in-flight implementation.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
**The problem.** paliad's deadline knowledge today is fragmented across five tables and two parallel calculators. The structural truth m wants — *court system → proceeding → ordered event types → conditional trigger edges* — is mostly *implicit*: it lives partly in `deadline_rules.parent_id` (one-parent tree per proceeding), partly in `trigger_events`+`event_deadlines` (flat YouPC import), partly in `deadline_concepts` (cross-proceeding semantic bridge), partly in `event_categories` (Pathway-B navigation taxonomy), and partly in free-text columns on `paliad.projects`. Conditions are encoded *twice* — once via `condition_rule_id` (FK to a sibling rule), once via `condition_flag text[]` (named flags). Multi-parent triggers cannot be expressed cleanly. The court-system axis is missing entirely.
|
||||
|
||||
**What m wants** (verbatim, 2026-05-08 16:01):
|
||||
|
||||
> All I want is a natural sequence of proceedings which belong to a court system. And of course we can classify deadlines into concepts and make it easier for the AI to understand, but in its core I need event types that are related to proceedings and connected as a sequence, one triggering the other, with some conditions possibly changing the resulting sequence.
|
||||
|
||||
**Locked m decisions (this doc, AskUserQuestion 2026-05-08 16:13–16:18):**
|
||||
|
||||
| Q | Subject | Lock |
|
||||
|---|---|---|
|
||||
| Q1 | Court-system axis | **Reuse `courts.court_type` as the system identity.** Promote it to a `paliad.court_types` lookup. FK `paliad.courts.court_type` → `court_types.code`. Retire `paliad.proceeding_types.jurisdiction`. |
|
||||
| Q2 | Proceeding instance | **Project (or sub-project) IS the proceeding instance.** Verbatim m: *"Each UPC proceeding should be its own (sub-)project. And as such can be one proceeding or multiple if necessary. Flexibility is key."* No new `paliad.proceedings` table. Multi-proceeding cases use sub-projects in the existing project tree. |
|
||||
| Q3 | Edge model | **First-class `paliad.proceeding_event_edges` table.** Multi-parent triggers natural. `parent_id` on the legacy `deadline_rules` table retired. |
|
||||
| Q4 | Conditions | **Typed columns per edge:** `if_flags text[]` (all must be set), `unless_flags text[]` (none may be set), `requires_event_id uuid REFERENCES proceeding_event_types(id)`. SQL-queryable; no expression evaluator. |
|
||||
| Q5 | Concept layer | **Subsume `deadline_concepts` into `proceeding_event_types.concept_slug` column.** Drop `deadline_concepts` table after backfill. Keep `event_categories` recursive tree as Pathway-B navigation overlay only — re-FK its junction onto `concept_slug`. |
|
||||
|
||||
**Headline shape change.** Today's *two-rule-libraries-bridged-by-a-mat-view* becomes *one rule library: a graph of typed event-types connected by typed edges, scoped to proceedings, scoped to court systems*. The instance side stays where it is (project tree). The AI/UX layers (concept tags, navigation tree) ride on top of the graph rather than parallel to it.
|
||||
|
||||
**Migration shape.** Additive build → atomic cutover per surface (Fristenrechner, deadline-search, /deadlines/new picker), all on the same boot. The 26 production `paliad.deadlines` rows survive untouched (their `rule_code` text already carries the citation; `rule_id` re-points to the new event-type/edge tuple post-cutover).
|
||||
|
||||
---
|
||||
|
||||
## 1. Map of current state
|
||||
|
||||
### 1.1 The five tables that carry deadline knowledge
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.proceeding_types (26) │ jurisdiction text
|
||||
│ ─ INF, REV, CCR, APM, … │ ('UPC'|'DE'|'EPA'|'DPMA')
|
||||
│ ─ UPC_INF, UPC_REV, UPC_PI… │
|
||||
│ ─ DE_INF, DE_NULL, DE_*_BGH │
|
||||
│ ─ EPA_OPP, EPA_APP, EP_GRANT│
|
||||
│ ─ DPMA_OPP, DPMA_*_BPATG… │
|
||||
└──────────┬───────────────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ paliad.deadline_rules (172) │
|
||||
│ ─ uuid PK │
|
||||
│ ─ proceeding_type_id int FK │
|
||||
│ ─ parent_id uuid → self (one-parent tree) │
|
||||
│ ─ code, name_de, name_en, description │
|
||||
│ ─ primary_party (claimant|defendant|both|court) │
|
||||
│ ─ event_type (filing|decision|order|hearing) │
|
||||
│ ─ duration_value int, duration_unit text │
|
||||
│ (months|weeks|days|working_days) │
|
||||
│ ─ timing (after|before) │
|
||||
│ ─ rule_code text, deadline_notes text+_en │
|
||||
│ ─ legal_source text ← t-paliad-131 Phase A │
|
||||
│ ─ concept_id uuid FK ← t-paliad-131 Phase A │
|
||||
│ ─ condition_rule_id uuid ─┐ │
|
||||
│ ─ condition_flag text[] ├ TWO mechanisms, │
|
||||
│ ─ alt_duration_* / unit │ one structural idea │
|
||||
│ ─ alt_rule_code │ │
|
||||
│ ─ anchor_alt text ─┘ │
|
||||
│ ─ is_spawn bool, spawn_label text │
|
||||
│ ─ is_bilateral bool ← t-paliad-133 Phase A │
|
||||
│ ─ sequence_order int │
|
||||
└──────┬───────────────────────────────────────────────┘
|
||||
│ concept_id (uuid)
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.deadline_concepts (57)│ the Unifier layer
|
||||
│ ─ slug UNIQUE │ (t-paliad-131 Phase A)
|
||||
│ ─ name_de, name_en │
|
||||
│ ─ aliases text[] │
|
||||
│ ─ party text │
|
||||
│ ─ category (submission| │
|
||||
│ decision|order|hearing) │
|
||||
└──────┬───────────────────────┘
|
||||
│ concept_id (uuid)
|
||||
▼ (junction)
|
||||
┌──────────────────────────────────────────┐
|
||||
│ paliad.event_category_concepts (136) │ decision-tree leaf
|
||||
│ ─ event_category_id FK │ → concept overlay
|
||||
│ ─ concept_id FK │ (t-paliad-133)
|
||||
│ ─ proceeding_type_code text -- narrow │
|
||||
└──────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.event_categories (103)│ recursive tree (parent_id self-FK)
|
||||
│ ─ slug, label_de, label_en │ Pathway-B navigation taxonomy
|
||||
│ ─ step_question_de/_en │ (t-paliad-133, depth unlimited)
|
||||
│ ─ icon, sort_order, is_leaf │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
**Parallel rule library — YouPC import (UPC-only, flat):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ paliad.trigger_events (110) │ 1:N │ paliad.event_deadlines (77) │
|
||||
│ ─ bigint PK (verbatim from │ ───────▶│ ─ bigint PK (verbatim ids) │
|
||||
│ youpc.data.events) │ │ ─ trigger_event_id FK │
|
||||
│ ─ code, name, name_de │ │ ─ duration_value, duration_unit │
|
||||
│ ─ concept_id text │ │ (days|weeks|months| │
|
||||
│ ↑ slug (text), NOT FK │ │ working_days) │
|
||||
└──────────────────────────────┘ │ ─ alt_duration_* + combine_op │
|
||||
│ (max|min — composite │
|
||||
│ rule for R.198/R.213) │
|
||||
│ ─ timing (before|after) │
|
||||
│ ─ title, title_de, notes, _en │
|
||||
└──────────┬───────────────────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ paliad.event_deadline_rule_codes │
|
||||
│ (72 — one row per RoP citation) │
|
||||
│ ─ event_deadline_id, rule_code, │
|
||||
│ sort_order │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
The two rule libraries are bridged at search time only by `paliad.deadline_search` (mat-view, t-paliad-131 Phase C, migration 047): one row per (concept × context) where context is `kind='rule'` for `deadline_rules` rows or `kind='trigger'` for `trigger_events` rows. They share **no FK**.
|
||||
|
||||
**Instance side** (the per-case audit row):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.deadlines (26 in production) │
|
||||
│ ─ uuid PK │
|
||||
│ ─ project_id uuid FK → paliad.projects(id) │
|
||||
│ ─ rule_id uuid FK → deadline_rules(id) NULL │
|
||||
│ ─ rule_code text -- citation, free-text │
|
||||
│ (t-paliad-111 — survives rule rename) │
|
||||
│ ─ title, description, due_date, original_due_ │
|
||||
│ date, warning_date │
|
||||
│ ─ status (pending|completed|cancelled|waived) │
|
||||
│ ─ source (manual|imported|caldav|paliadin) │
|
||||
│ ─ caldav_uid, caldav_etag │
|
||||
│ ─ approval_status (approved|pending|legacy) │
|
||||
│ + pending_request_id, approved_by/_at │
|
||||
│ (t-paliad-138 dual-control, migration 054)│
|
||||
│ ─ created_by, created_at, updated_at │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│ deadline_id
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.deadline_event_types (junction, 0..N) │
|
||||
│ ─ deadline_id, event_type_id (composite PK) │
|
||||
│ (t-paliad-088, migration 030) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ event_type_id
|
||||
│
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.event_types (45) │
|
||||
│ ─ uuid PK, slug, label_de, label_en │
|
||||
│ ─ category (submission|decision|order|service| │
|
||||
│ fee|hearing|other) │
|
||||
│ ─ jurisdiction (UPC|EPO|DPMA|DE|any) NULL │
|
||||
│ ─ trigger_event_id bigint NULL ─ loose linkage│
|
||||
│ (NO FK constraint — youpc resync-safe) │
|
||||
│ ─ created_by, is_firm_wide, archived_at │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`paliad.event_types` is the user-facing classifier on per-case `paliad.deadlines` rows. It overlaps with `paliad.trigger_events` by ~70% (UPC submissions) and carries an optional `trigger_event_id` linkage column without an FK constraint by design (so a future YouPC re-sync can drop trigger ids without breaking event_types). It is **distinct from** `paliad.event_categories` (the Pathway-B decision tree) and **distinct from** `paliad.deadline_rules.event_type` (which is just a text column, values `filing|decision|order|hearing`).
|
||||
|
||||
So today the word "event type" identifies three different things in three different tables. Not necessarily wrong, but worth flagging.
|
||||
|
||||
### 1.2 Court / venue / jurisdiction
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ paliad.courts (41 — t-paliad-122 migration 053) │
|
||||
│ ─ id text PK (kebab, mirrors handlers/courts.go) │
|
||||
│ ─ code, name_de, name_en │
|
||||
│ ─ country text FK → paliad.countries(code) -- ISO-3166 │
|
||||
│ ─ regime text NULL -- 'UPC'|'EPO'|NULL │
|
||||
│ ─ court_type text -- 'UPC-LD'|'UPC-CD'|'UPC-CoA'| │
|
||||
│ 'DE-LG'|'DE-OLG'|'DE-BGH'| │
|
||||
│ 'DE-BPatG'|'DE-DPMA'|'EPA'|'NAT' │
|
||||
│ ─ parent_id text FK → self │
|
||||
│ ─ sort_order int, is_active bool │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The `court_type` column is currently **free text** (no constraint, no FK target). 41 rows are seeded across 11 distinct values. This is the column m's Q1 lock promotes to be the court-system identity.
|
||||
|
||||
`paliad.holidays` (55 rows) carries `country` ISO-3166 + `regime` ('UPC'|'EPO'|NULL). Federal DE public holidays = country='DE', regime=NULL; UPC summer/winter judicial vacations = country=NULL, regime='UPC'. The check constraint `country IS NOT NULL OR regime IS NOT NULL` enforces every row carries at least one.
|
||||
|
||||
### 1.3 Project side — what links a case to a proceeding today
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ paliad.projects (11 active in prod) │
|
||||
│ ─ id uuid PK │
|
||||
│ ─ type text -- 'mandat'|'litigation'|'patent'| │
|
||||
│ 'verfahren'|'projekt' │
|
||||
│ ─ parent_id uuid → self (project tree) │
|
||||
│ ─ path text NOT NULL -- materialised ltree path │
|
||||
│ (t-paliad-023, GiST-indexed, RLS-load-bearing) │
|
||||
│ ─ title, reference, description, status │
|
||||
│ ─ proceeding_type_id integer -- single FK │
|
||||
│ → paliad.proceeding_types(id) │
|
||||
│ ─ court text -- FREE TEXT, no FK to paliad.courts │
|
||||
│ ─ country text │
|
||||
│ ─ patent_number, filing_date, grant_date │
|
||||
│ ─ case_number, billing_reference, client_number, │
|
||||
│ matter_number, netdocuments_url │
|
||||
│ ─ industry, ai_summary, metadata jsonb │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
So the project row carries:
|
||||
- ONE proceeding-type FK (an integer, not nullable on `verfahren` projects but nullable in the schema).
|
||||
- ONE court — but as **free text**, not FK'd to `paliad.courts.id` despite that table being seeded six days ago in migration 053.
|
||||
- NO trigger_date column. The trigger date is implicit in the `paliad.deadlines.original_due_date` of whichever Frist anchored the calc.
|
||||
- NO live-state column. There is no "currently at stage X" pointer.
|
||||
|
||||
There's no `paliad.proceedings` table. The conceptual link "this project IS a UPC infringement action" is the pair (project_id → proceeding_type_id), no further structure.
|
||||
|
||||
### 1.4 What lives where — by jurisdiction
|
||||
|
||||
| Jurisdiction | proceeding_types | deadline_rules | trigger_events | event_deadlines |
|
||||
|---|---:|---:|---:|---:|
|
||||
| UPC (legacy: INF/REV/CCR/APM/APP/AMD) | 6 | 36 | 0 | 0 |
|
||||
| UPC (modern: UPC_INF/UPC_REV/UPC_PI/…) | 8 | 56 | 110 | 77 |
|
||||
| DE (ZPO/PatG, LG/OLG/BGH/BPatG) | 5 | 40 | 0 | 0 |
|
||||
| EPA (OPP/APP/EP_GRANT) | 3 | 23 | 0 | 0 |
|
||||
| DPMA | 3 | 13 | 0 | 0 |
|
||||
| Cross-cutting (Wiedereinsetzung, …) | 0 | 0 | 7 | 7 |
|
||||
| Legacy ZPO_CIVIL placeholder | 1 | 4 | 0 | 0 |
|
||||
| **Total** | **26** | **172** | **110** | **77** |
|
||||
|
||||
The two UPC generations (`INF/REV/CCR/APM/APP/AMD` from migration 008 vs `UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_COST_APPEAL/UPC_APP_ORDERS` from migration 012) coexist in production. Fristenrechner v3+ uses the modern set; the legacy six are unreferenced sediment kept "in case". This is technical debt orthogonal to the model question, flagged here for the migration plan in §4.
|
||||
|
||||
### 1.5 How conditional triggers are encoded today (concrete)
|
||||
|
||||
| Mechanism | Rules using it | Example |
|
||||
|---|---:|---|
|
||||
| `condition_rule_id` (FK to a sibling rule) | 2 | INF tree's `inf.reply` and `inf.rejoin` reference `ccr.counterclaim` — when CCR was filed in the same case, swap rule_code RoP.029.b → RoP.029.a (Reply) or duration 1mo → 2mo (Rejoinder). |
|
||||
| `condition_flag text[]` (named flags from request) | 17 | UPC_INF tree's `with_ccr` rules render only when the request includes `with_ccr` flag; UPC_REV's `with_amend`/`with_cci` parallel flags. |
|
||||
| `alt_duration_value` + `alt_duration_unit` + `alt_rule_code` | 4 | Swap-on-flag fallback (R.198/R.213 max-of-31d-or-20wd is encoded similarly on `event_deadlines.alt_duration_*` + `combine_op`). |
|
||||
| `anchor_alt text` (named alternate anchor) | 1 | EP_GRANT publish anchors on `priority_date` instead of parent rule's date. |
|
||||
| `is_spawn` + `spawn_label` (cross-tree edge) | 6 | INF tree's `inf.appeal` lives in APP tree but `parent_id` points into INF.decision — the rule itself sits in proceeding APP, the parent sits in proceeding INF. Implicit cross-proceeding edge. |
|
||||
| `condition_flag` AND `alt_duration_value` together | 3 | UPC_INF Replik has `condition_flag=['with_ccr']` swapping duration via `alt_duration_value` rather than gating render. |
|
||||
|
||||
The two-mechanism split is what bites every contributor. `condition_rule_id` was the Phase-A approach; `condition_flag` was added by t-paliad-086 PR-3 because `condition_rule_id` couldn't model "user told me they ARE in CCR mode without there being a rule of mine to point at." Both still in production. New rules should use `condition_flag`; the 2 legacy `condition_rule_id` rules are equivalent to single-element flag arrays and were not migrated.
|
||||
|
||||
### 1.6 The two calculators
|
||||
|
||||
- **Tree calculator** — `internal/services/fristenrechner.go` (803 lines): walks `deadline_rules` parent_id chain, anchors on input trigger_date, applies condition_flag gates, swaps `alt_*` columns when flags are set, classifies court-determined nodes (`isCourtDeterminedRule`: `primary_party='court' OR event_type IN ('hearing','decision','order')`) so they render as "no date — court will set it". Used by `/tools/fristenrechner` for the 16 modern proceeding-tree views.
|
||||
- **Flat calculator** — `internal/services/event_deadline_service.go` (315 lines): single trigger_event ID + trigger_date → list of event_deadlines, no parent chain. Composite `combine_op='max'`/`'min'` resolves R.198/R.213. Working-days math via `addWorkingDays` over `paliad.holidays`. Used by Pathway-B "Was kommt nach…" tab.
|
||||
|
||||
The two share `holidays.go` for working-day skip logic. Otherwise the code paths are independent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gaps vs proceedings-as-DAG framing
|
||||
|
||||
m's framing decoded into structural facts the data model SHOULD support:
|
||||
|
||||
| m says | Data model needs |
|
||||
|---|---|
|
||||
| "court system" is the outer container | One row per court system the firm practises in (UPC-CFI, UPC-CoA, DE-LG-Patentkammer, DE-OLG, DE-BGH, DE-BPatG, EPO, DPMA, …). Procedural rules belong to a court system. |
|
||||
| "a natural sequence of proceedings" | One row per *named procedural shape* (UPC infringement action, UPC revocation action, EPO opposition, DE LG patent action). A proceeding belongs to ONE court system. |
|
||||
| "event types … related to proceedings" | Each event-type node belongs to a proceeding. Some nodes may be shared across proceedings (final-decision, oral-hearing). |
|
||||
| "connected as a sequence, one triggering the other" | Edges between event-types within a proceeding. Multi-parent allowed (one node may be triggered by either of two predecessors). |
|
||||
| "with some conditions possibly changing the resulting sequence" | Edges carry conditions. Conditions are first-class (queryable, AI-readable). |
|
||||
| "classify deadlines into concepts and make it easier for the AI" | Concept tag layer on each event-type. Rides on top of the graph, doesn't compete with it. |
|
||||
|
||||
### 2.1 Concrete gaps
|
||||
|
||||
#### Gap G1 — Court system is not in the data model
|
||||
|
||||
**Today:** `proceeding_types.jurisdiction text` ('UPC'|'DE'|'EPA'|'DPMA') conflates court-system regime with national jurisdiction. The 41 `paliad.courts` rows carry `court_type` ('UPC-LD'|'UPC-CoA'|'DE-LG'|'DE-OLG'|'DE-BGH'|'DE-BPatG'|'EPA'|'DPMA'|'NAT'|…) as free text. There is no FK between the two.
|
||||
|
||||
**Why it bites:** "Show me every UPC procedural rule" requires `proceeding_types.jurisdiction='UPC'`. "Show me every rule that fires in a German LG patent chamber" requires reasoning about court_type='DE-LG' AND a proceeding that runs there — but the proceeding doesn't carry a court_type, the *project's court* does, and that's free text. The DE-LG and DE-OLG patent appeal proceedings (`DE_INF`, `DE_INF_OLG`) BOTH have jurisdiction='DE' on `proceeding_types`; nothing tells you DE_INF runs at LG and DE_INF_OLG runs at OLG except the proceeding name.
|
||||
|
||||
**Concrete fail:** today, the holiday lookup for "deadline computed for a UPC infringement action filed in München LD" needs UPC summer vacation + DE federal holidays. The intermediate join (project.court_type → applicable holiday set) is hardcoded in `internal/services/holidays.go` because there's no FK chain to walk.
|
||||
|
||||
#### Gap G2 — One project = one proceeding-type FK; multi-proceeding cases are forced into the project tree
|
||||
|
||||
**Today:** `paliad.projects.proceeding_type_id integer` is single-valued. A project that hosts BOTH a UPC infringement action and a separate revocation counterclaim must either:
|
||||
(a) Tag itself with one of the two and lose half its proceeding context, or
|
||||
(b) Be split into two child `verfahren` projects under a common litigation parent.
|
||||
|
||||
**m's lock (Q2):** Sub-projects are the right answer. *"Each UPC proceeding should be its own (sub-)project."* This is consistent with the project-tree model already in place since t-paliad-023 (data-model-v2). The fix isn't to add a `paliad.proceedings` table; it's to *honour* the existing tree by FK-tightening `projects.proceeding_def_id` on `verfahren`-typed projects.
|
||||
|
||||
#### Gap G3 — Edges are one-parent only; multi-parent triggers cannot be expressed cleanly
|
||||
|
||||
**Today:** Each `deadline_rules` row has at most one `parent_id`. A node like UPC `inf.rejoin` has TWO real-world predecessors:
|
||||
- After Reply-to-SoD when no CCR was filed (1 month, RoP.029.c)
|
||||
- After Reply-to-Defence-to-CCR when CCR was filed (1 month, RoP.029.e)
|
||||
|
||||
The current model collapses these into ONE rule with `condition_flag=['with_ccr']` swapping `alt_*` columns, but that masks the true graph: there are two distinct edges into `inf.rejoin`, with different `from_event_type` and different `rule_code`. Today the calculator papers over this by anchoring `inf.rejoin` on whichever parent the `parent_id` points at and pretending the other parent doesn't exist for purposes of the chain walk.
|
||||
|
||||
Cross-proceeding edges (the legacy `is_spawn` flag, 6 rules) are an even uglier symptom — `inf.appeal` lives in proceeding APP but its `parent_id` points into INF. Two different proceedings, one edge. Today this is fine for tree traversal but breaks any "show me proceeding APP's structure" query because you have to know the edge crosses.
|
||||
|
||||
#### Gap G4 — Conditions encoded in two mechanisms
|
||||
|
||||
**Today:** 2 rules use `condition_rule_id` (FK to a sibling rule whose presence flips alt_duration / alt_rule_code), 17 rules use `condition_flag text[]` (named flags). Both still load-bearing in the calculator. Same idea, two columns.
|
||||
|
||||
**Why it bites:** Every new contributor has to learn both. The 2 legacy `condition_rule_id` rules are sentinel debt — they couldn't be deleted without rewriting the inf.reply / inf.rejoin classifier_flag dual-encoding (memory `652b856f` t-paliad-086 PR-3 imported the flag-based variant alongside, did NOT migrate the legacy two).
|
||||
|
||||
#### Gap G5 — Two parallel rule libraries with no shared FK
|
||||
|
||||
**Today:**
|
||||
- `deadline_rules` (172 rows, UUID PK, parent-tree, condition_flag, alt_*) — the timeline calculator's source.
|
||||
- `trigger_events` + `event_deadlines` (110+77 rows, bigint PK, flat trigger→deadline map, composite max/min) — the trigger calculator's source.
|
||||
|
||||
They are bridged at search time by `paliad.deadline_search` mat-view (concept slug as join key) but share no FK. A rule in `deadline_rules` and a deadline in `event_deadlines` can describe the *same* legal idea (e.g. UPC Klageerwiderung) and the only thing that ties them is whether someone happened to set the same `concept_id`/`concept slug` on both sides.
|
||||
|
||||
This costs us:
|
||||
- **Drift** — when t-paliad-086 PR-3 fixed Tier-1 bugs in `deadline_rules`, equivalent rows in `event_deadlines` were not touched. The two libraries can disagree on the same Frist.
|
||||
- **Audit difficulty** — "is this Frist correct?" requires reading both tables and the bridge.
|
||||
- **AI confusion** — feeding the corpus to the LLM means feeding two different shapes of the same knowledge.
|
||||
|
||||
#### Gap G6 — Concept layer is a rope-bridge, not a column
|
||||
|
||||
**Today:** `paliad.deadline_concepts` (57 rows) is a separate table. `deadline_rules.concept_id uuid FK`. `trigger_events.concept_id text` (slug, NOT FK — string-walked). `event_category_concepts.concept_id uuid FK` (the navigation overlay). Three different referent types for the same entity.
|
||||
|
||||
**Why it bites:** Re-naming a concept (slug change) means walking three FK shapes. AI ingestion means joining four tables to get "what does this Frist *mean*." The cross-proceeding semantic identity (one Klageerwiderung in UPC ≅ one Klageerwiderung in DE_INF) is queryable but not load-bearing — the FK exists, but nothing constrains *both* rules to point at the same concept_id. Drift is silent.
|
||||
|
||||
#### Gap G7 — Conditional sequence changes are local to one edge
|
||||
|
||||
**Today:** A condition on rule X (e.g. `condition_flag=['with_ccr']`) gates whether rule X renders. It does NOT propagate. So if "with_ccr is true" should *also* mean "the Application-to-amend timeline becomes available in this proceeding," that's encoded as separate rules each with their own `condition_flag=['with_ccr']`. No "if condition C, the proceeding switches to track T" semantic.
|
||||
|
||||
**Concrete example:** UPC infringement with CCR has its OWN sub-proceeding shape (Defence-to-CCR with its own Reply/Rejoinder cycle, optional Application-to-amend). Today this is encoded as N additional rules in `UPC_INF` each gated on `with_ccr`. Tomorrow it could be one `proceeding_event_edges` row that says "if `with_ccr` then activate the CCR sub-graph rooted at this node."
|
||||
|
||||
This is **not** addressed by Q3+Q4 — multi-parent edges + typed conditions. We'll *come closer*, but a true track-switching semantic ("this proceeding has an alternate path that engages under condition X") is one level above the edge model and is **deliberately deferred**. See §6.4.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target shape
|
||||
|
||||
This section translates m's locked decisions into a concrete schema and walks one full UPC infringement action to make the shape tangible.
|
||||
|
||||
### 3.1 Court system axis (Q1)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.court_types (
|
||||
code text PRIMARY KEY,
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
regime text -- 'UPC'|'EPO'|NULL (national)
|
||||
CHECK (regime IS NULL OR regime IN ('UPC','EPO')),
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO paliad.court_types (code, name_de, name_en, regime, sort_order) VALUES
|
||||
-- UPC court systems
|
||||
('UPC-LD', 'UPC-Lokalkammer', 'UPC Local Division', 'UPC', 10),
|
||||
('UPC-CD', 'UPC-Zentralkammer', 'UPC Central Division', 'UPC', 20),
|
||||
('UPC-CoA', 'UPC-Berufungsgericht', 'UPC Court of Appeal', 'UPC', 30),
|
||||
('UPC-RD', 'UPC-Regionalkammer', 'UPC Regional Division', 'UPC', 40),
|
||||
-- DE court systems
|
||||
('DE-LG', 'Landgericht (Patentstreitkammer)',
|
||||
'German Regional Court (patent chamber)', NULL, 50),
|
||||
('DE-OLG', 'Oberlandesgericht (Patentsenat)',
|
||||
'German Higher Regional Court (patent senate)', NULL, 60),
|
||||
('DE-BGH', 'Bundesgerichtshof (X. Zivilsenat)',
|
||||
'German Federal Court of Justice (Xth Civil Senate)', NULL, 70),
|
||||
('DE-BPatG', 'Bundespatentgericht', 'German Federal Patent Court', NULL, 80),
|
||||
('DE-DPMA', 'Deutsches Patent- und Markenamt',
|
||||
'German Patent and Trade Mark Office', NULL, 90),
|
||||
-- EPO
|
||||
('EPA', 'Europäisches Patentamt', 'European Patent Office', 'EPO', 100),
|
||||
-- National (non-UPC, non-DE-patent-track)
|
||||
('NAT', 'Nationales Gericht', 'National Court', NULL, 200);
|
||||
|
||||
-- FK from existing courts table
|
||||
ALTER TABLE paliad.courts
|
||||
ADD CONSTRAINT courts_court_type_fk
|
||||
FOREIGN KEY (court_type) REFERENCES paliad.court_types(code);
|
||||
```
|
||||
|
||||
The 41 `paliad.courts` rows already carry the right `court_type` strings (verified live: 11 distinct values, all in the seed list above). The FK addition is a pure constraint upgrade, no data move.
|
||||
|
||||
### 3.2 Proceeding definitions (the named-sequence template)
|
||||
|
||||
```sql
|
||||
-- Renamed + restructured from paliad.proceeding_types
|
||||
CREATE TABLE paliad.proceeding_definitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
-- 'UPC_INF','UPC_REV','UPC_PI','UPC_APP','EPO_OPP',
|
||||
-- 'EPO_APP','DE_INF_LG','DE_INF_OLG','DE_INF_BGH',
|
||||
-- 'DE_NULL_BPATG','DE_NULL_BGH','DPMA_OPP','DPMA_APP','DPMA_RB'
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
court_type text NOT NULL
|
||||
REFERENCES paliad.court_types(code), -- the system axis
|
||||
category text NOT NULL -- 'litigation'|'opposition'|'examination'|'appeal'
|
||||
CHECK (category IN ('litigation','opposition','examination',
|
||||
'appeal','enforcement','provisional')),
|
||||
default_color text NOT NULL DEFAULT '#3b82f6',
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
is_fristenrechner bool NOT NULL DEFAULT true,
|
||||
-- whether this proceeding is exposed in /tools/fristenrechner
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX proceeding_definitions_court_type_idx
|
||||
ON paliad.proceeding_definitions(court_type);
|
||||
CREATE INDEX proceeding_definitions_category_idx
|
||||
ON paliad.proceeding_definitions(category);
|
||||
```
|
||||
|
||||
Each row IS a "natural sequence of [a class of] proceedings." `court_type` is the outer container m asked for. The legacy `proceeding_types.jurisdiction` text column is dropped — its information is now derivable via `court_types.regime`.
|
||||
|
||||
### 3.3 Event types (the nodes)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.proceeding_event_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proceeding_def_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
|
||||
-- Each node belongs to one proceeding. Cross-proceeding shared
|
||||
-- semantics are expressed via concept_slug (Q5 lock), not by
|
||||
-- attaching one node to multiple proceedings.
|
||||
code text NOT NULL,
|
||||
-- Local code, unique within proceeding_def_id.
|
||||
-- Examples: 'soc','sod','reply','rejoinder','decision'
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
party text NOT NULL
|
||||
CHECK (party IN ('claimant','defendant','both','court','any')),
|
||||
kind text NOT NULL
|
||||
CHECK (kind IN ('filing','decision','order','hearing','service','fee')),
|
||||
concept_slug text, -- Q5 lock — subsumes paliad.deadline_concepts
|
||||
-- Free-form slug; matches old concept slugs verbatim post-migration.
|
||||
-- One LLM-readable identifier shared across proceedings.
|
||||
-- E.g. 'statement-of-defence' on both UPC_INF.sod and DE_INF_LG.klageerw.
|
||||
concept_de text, -- denormalised from old deadline_concepts.name_de
|
||||
concept_en text, -- denormalised from old deadline_concepts.name_en
|
||||
aliases text[] NOT NULL DEFAULT '{}',
|
||||
-- Search aliases inherited from old deadline_concepts.aliases.
|
||||
-- Indexed via gin (aliases) for the search bar.
|
||||
is_root bool NOT NULL DEFAULT false,
|
||||
-- True for the trigger node of a proceeding (the Statement of Claim,
|
||||
-- the Statement for Revocation, the EPO opposition filing). The
|
||||
-- proceeding instance's trigger_date anchors here.
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
is_bilateral bool NOT NULL DEFAULT false,
|
||||
-- Carried over from t-paliad-133. When true AND party='both',
|
||||
-- mirror into both columns of the columns-view.
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (proceeding_def_id, code)
|
||||
);
|
||||
|
||||
CREATE INDEX proceeding_event_types_def_idx ON paliad.proceeding_event_types(proceeding_def_id);
|
||||
CREATE INDEX proceeding_event_types_concept_idx ON paliad.proceeding_event_types(concept_slug)
|
||||
WHERE concept_slug IS NOT NULL;
|
||||
CREATE INDEX proceeding_event_types_aliases_idx ON paliad.proceeding_event_types USING gin (aliases);
|
||||
CREATE INDEX proceeding_event_types_de_trgm ON paliad.proceeding_event_types USING gin (name_de gin_trgm_ops);
|
||||
CREATE INDEX proceeding_event_types_en_trgm ON paliad.proceeding_event_types USING gin (name_en gin_trgm_ops);
|
||||
```
|
||||
|
||||
Per Q5: `concept_slug` + `concept_de` + `concept_en` + `aliases` are columns on the node, not a separate table. The 57 `paliad.deadline_concepts` rows distill into ~57 distinct concept_slug values across the ~172+ migrated nodes. Cross-proceeding "all rules with concept_slug='statement-of-defence'" is a single-column index lookup, not a join.
|
||||
|
||||
### 3.4 Edges (the typed triggers)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.proceeding_event_edges (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proceeding_def_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
|
||||
from_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
|
||||
-- NULL = root edge (anchors on the proceeding instance's trigger_date).
|
||||
-- The to_event must have is_root=true for null-from edges.
|
||||
to_event_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
|
||||
duration_value int NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months'
|
||||
CHECK (duration_unit IN ('days','weeks','months','working_days')),
|
||||
timing text NOT NULL DEFAULT 'after'
|
||||
CHECK (timing IN ('after','before')),
|
||||
-- 'before' supports countdown deadlines (e.g. "1 month before oral hearing").
|
||||
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max','min')),
|
||||
alt_duration_value int,
|
||||
alt_duration_unit text CHECK (alt_duration_unit IS NULL
|
||||
OR alt_duration_unit IN ('days','weeks','months','working_days')),
|
||||
-- combine_op + alt_* implements composite rules
|
||||
-- (e.g. R.198/R.213 max(31d, 20wd)). Only set on edges
|
||||
-- where the rule itself is composite — flag-conditioned
|
||||
-- variants use sibling edges, not alt_*.
|
||||
|
||||
-- ===== Q4 lock — typed conditions =====
|
||||
if_flags text[] NOT NULL DEFAULT '{}',
|
||||
-- All flags in this array must be set for the edge to fire.
|
||||
-- Empty array = unconditional.
|
||||
unless_flags text[] NOT NULL DEFAULT '{}',
|
||||
-- None of these flags may be set for the edge to fire.
|
||||
requires_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE SET NULL,
|
||||
-- Edge fires only if this OTHER event was actually filed/recorded
|
||||
-- in the proceeding instance (replaces today's condition_rule_id).
|
||||
-- NULL = no occurrence prerequisite.
|
||||
|
||||
-- ===== Citation =====
|
||||
rule_code text, -- 'RoP.029.b','PatG §111(1)','§ 276 ZPO'
|
||||
legal_source text, -- 'UPC.RoP.029.b' / 'DE.PatG.111.1' / 'EU.EPÜ.108'
|
||||
is_mandatory bool NOT NULL DEFAULT true,
|
||||
deadline_notes_de text,
|
||||
deadline_notes_en text,
|
||||
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Sanity: from_event must belong to the same proceeding_def
|
||||
-- (cross-proceeding edges are out-of-scope per §3.6 — modelled
|
||||
-- via separate root edges in each proceeding instead).
|
||||
CONSTRAINT edge_from_in_def CHECK (
|
||||
from_event_id IS NULL OR proceeding_def_id IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX edges_def_idx ON paliad.proceeding_event_edges(proceeding_def_id);
|
||||
CREATE INDEX edges_to_idx ON paliad.proceeding_event_edges(to_event_id);
|
||||
CREATE INDEX edges_from_idx ON paliad.proceeding_event_edges(from_event_id)
|
||||
WHERE from_event_id IS NOT NULL;
|
||||
CREATE INDEX edges_requires_idx ON paliad.proceeding_event_edges(requires_event_id)
|
||||
WHERE requires_event_id IS NOT NULL;
|
||||
CREATE INDEX edges_if_flags_idx ON paliad.proceeding_event_edges USING gin (if_flags);
|
||||
CREATE INDEX edges_unless_flags_idx ON paliad.proceeding_event_edges USING gin (unless_flags);
|
||||
CREATE INDEX edges_rule_code_idx ON paliad.proceeding_event_edges(rule_code)
|
||||
WHERE rule_code IS NOT NULL;
|
||||
```
|
||||
|
||||
**Multi-parent semantics:** when two edges share the same `to_event_id`, both compute candidate dates; the calculator picks per the edges' `if_flags`/`unless_flags`/`requires_event_id` predicates. If multiple edges remain feasible for the same target, the rendered Frist is the LATEST of the candidates (paying lip service to the most-conservative-first principle); a future edge-priority column can refine this if needed.
|
||||
|
||||
**Composite within an edge** (`combine_op`): used only when the rule itself is structurally composite (R.198 / R.213 max-of-two-units). Flag-driven variants (`with_ccr` swaps duration 1mo→2mo) become **two sibling edges** with disjoint `if_flags` predicates — the cleaner expression of the same idea.
|
||||
|
||||
### 3.5 Project ↔ proceeding linkage (Q2)
|
||||
|
||||
```sql
|
||||
-- Per Q2 lock — project (or sub-project) IS the proceeding instance.
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN proceeding_def_id uuid
|
||||
REFERENCES paliad.proceeding_definitions(id),
|
||||
ADD COLUMN court_id text
|
||||
REFERENCES paliad.courts(id),
|
||||
ADD COLUMN proceeding_trigger_date date,
|
||||
-- The date that anchors the root edge of this proceeding.
|
||||
-- Null until the trigger-event has actually occurred.
|
||||
ADD COLUMN proceeding_status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (proceeding_status IN ('pending','active','suspended','concluded','withdrawn'));
|
||||
|
||||
-- Backfill from the existing integer FK + free-text court column.
|
||||
UPDATE paliad.projects p
|
||||
SET proceeding_def_id = pd.id
|
||||
FROM paliad.proceeding_definitions pd
|
||||
JOIN paliad.proceeding_types pt ON pt.code = pd.code
|
||||
WHERE p.proceeding_type_id = pt.id;
|
||||
|
||||
-- Free-text court → FK by best-effort string match.
|
||||
UPDATE paliad.projects p
|
||||
SET court_id = c.id
|
||||
FROM paliad.courts c
|
||||
WHERE p.court IS NOT NULL
|
||||
AND lower(p.court) IN (lower(c.id), lower(c.code), lower(c.name_de), lower(c.name_en));
|
||||
|
||||
-- After backfill (separate migration, gated on QA):
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN country; -- inferred via court → court_type → country
|
||||
```
|
||||
|
||||
A `verfahren`-typed project carries `proceeding_def_id` (the template) + `court_id` (the venue) + `proceeding_trigger_date` (the anchor for downstream edges). A `mandat`/`litigation`-typed project does NOT carry these (NULL is fine). Multi-proceeding cases live as sibling `verfahren` projects under a shared parent — exactly m's lock.
|
||||
|
||||
The `proceeding_status` column gives the per-instance live state m wanted (pending → active → concluded) without a separate `paliad.proceedings` table. Future fields (current-stage event_type_id, last_advanced_at, expected-decision-date) extend this column set without disturbing other layers.
|
||||
|
||||
### 3.6 Cross-proceeding edges — explicit retirement
|
||||
|
||||
The current `is_spawn` flag (6 rules) encodes "filing of A in proceeding X opens proceeding Y" by parking a rule in proceeding Y's tree with `parent_id` pointing into proceeding X. Concretely: `inf.appeal` lives in APP but its parent is INF.decision.
|
||||
|
||||
In the new shape: **each proceeding's graph is closed.** Cross-proceeding triggers are modelled at the *instance* layer — when the user records "decision in INF reached on date D," they instantiate a NEW `verfahren` sub-project (proceeding APP) with `proceeding_trigger_date=D`. The graph stays clean; the cross-proceeding step is a project-tree action, not an edge.
|
||||
|
||||
This is a small UX shift (today the appeal Frist auto-renders inside the INF timeline; tomorrow the user explicitly spawns the appeal sub-project to see its Fristen) but the alternative — letting `proceeding_event_edges` straddle proceedings — pollutes the model. Defer cross-proceeding-edge support; add a sub-project-creation shortcut on the decision-event UI instead.
|
||||
|
||||
### 3.7 Concept layer — what stays, what goes
|
||||
|
||||
**Drops:**
|
||||
- `paliad.deadline_concepts` (57 rows). Content lifts to `proceeding_event_types.concept_slug` + `concept_de` + `concept_en` + `aliases`.
|
||||
- `paliad.deadline_rules.concept_id` FK. Replaced by `proceeding_event_types.concept_slug` text column.
|
||||
- `paliad.trigger_events.concept_id text` (already a slug, was never an FK). Migrated to the matching `proceeding_event_types` rows — see §4.
|
||||
|
||||
**Stays:**
|
||||
- `paliad.event_categories` (103 rows) — Pathway-B navigation taxonomy. Recursive tree, decision-tree UI. Re-FK its junction onto `concept_slug`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
DROP CONSTRAINT event_category_concepts_concept_id_fkey;
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
ADD COLUMN concept_slug text;
|
||||
UPDATE paliad.event_category_concepts ecc
|
||||
SET concept_slug = dc.slug
|
||||
FROM paliad.deadline_concepts dc
|
||||
WHERE ecc.concept_id = dc.id;
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
ALTER COLUMN concept_slug SET NOT NULL,
|
||||
DROP COLUMN concept_id;
|
||||
```
|
||||
|
||||
The category tree is now a thin overlay that maps "user clicked 'Hinweisbeschluss'" to the set of concept_slugs whose nodes should appear as cards. No separate concept identity required — the slug is the bridge.
|
||||
|
||||
**Stays unchanged:**
|
||||
- `paliad.event_types` (45 rows) — the *instance-side* user-facing classifier on `paliad.deadlines`. Per t-paliad-088 this is firm-wide-or-private, archive-only, with optional loose-linkage `trigger_event_id`. Untouched by this design — it's a different layer (instance tag, not template node). After migration, the loose linkage column can be repurposed: `event_types.proceeding_event_type_id uuid` (still loose, still nullable) — maintained as a follow-up, not in scope for the cutover.
|
||||
|
||||
### 3.8 Worked example — UPC infringement action (with CCR variant)
|
||||
|
||||
The mermaid below is one full proceeding's graph in the new shape. **Solid edges fire unconditionally; dashed edges fire only when the labelled flag is set.** Multi-parent at `inf.rejoinder` is the headline shape change.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
inf_soc["📄 inf.soc<br/>Statement of Claim<br/>concept-slug: statement-of-claim<br/>kind: filing • party: claimant • is_root: true"]:::root
|
||||
|
||||
inf_prelim["⚠️ inf.prelim<br/>Preliminary Objection<br/>concept-slug: preliminary-objection<br/>RoP.019.1"]
|
||||
inf_sod["📄 inf.sod<br/>Statement of Defence<br/>concept-slug: statement-of-defence<br/>RoP.023"]
|
||||
inf_ccr["⚖️ inf.ccr_counterclaim<br/>Counterclaim for Revocation<br/>concept-slug: counterclaim-for-revocation<br/>RoP.025 • is_bilateral"]
|
||||
inf_amend["📐 inf.app_to_amend<br/>Application to amend patent<br/>concept-slug: application-to-amend-patent<br/>RoP.030"]
|
||||
|
||||
inf_reply_no["📝 inf.reply<br/>Reply to Defence (no CCR)<br/>concept-slug: reply-to-defence<br/>RoP.029.b"]
|
||||
inf_reply_w["📝 inf.reply_with_ccr<br/>Defence-to-CCR + Reply<br/>concept-slug: reply-to-defence<br/>RoP.029.a"]
|
||||
|
||||
inf_def_amend["📝 inf.defence_to_amend<br/>Defence to App-to-amend<br/>concept-slug: defence-to-amend-patent<br/>RoP.032.1"]
|
||||
|
||||
inf_rejoin["📝 inf.rejoinder<br/>Rejoinder<br/>concept-slug: rejoinder-to-reply<br/>RoP.029.c|RoP.029.d"]
|
||||
|
||||
inf_interim["🧑⚖️ inf.interim<br/>Interim Conference<br/>kind: hearing • party: court"]
|
||||
inf_oral["⚖️ inf.oral<br/>Oral Hearing<br/>kind: hearing • party: court"]
|
||||
inf_decision["🏛️ inf.decision<br/>Decision on the merits<br/>concept-slug: decision-on-merits<br/>kind: decision • party: court"]
|
||||
inf_costs["💰 inf.cost_application<br/>Application for cost decision<br/>concept-slug: application-for-cost-decision<br/>RoP.151 • 1mo from decision"]
|
||||
|
||||
inf_soc -- "1mo" --> inf_prelim
|
||||
inf_soc -- "3mo (RoP.023)" --> inf_sod
|
||||
inf_soc -- "3mo (RoP.025)<br/>if_flags: with_ccr" -.-> inf_ccr
|
||||
inf_soc -- "3mo (RoP.030)<br/>if_flags: with_amend" -.-> inf_amend
|
||||
|
||||
inf_sod -- "2mo (RoP.029.b)<br/>unless_flags: with_ccr" --> inf_reply_no
|
||||
inf_ccr -- "2mo (RoP.029.a)" --> inf_reply_w
|
||||
|
||||
inf_amend -- "2mo (RoP.032.1)<br/>requires_event: inf.app_to_amend" -.-> inf_def_amend
|
||||
|
||||
inf_reply_no -- "1mo (RoP.029.c)<br/>unless_flags: with_ccr" --> inf_rejoin
|
||||
inf_reply_w -- "1mo (RoP.029.d)<br/>if_flags: with_ccr" --> inf_rejoin
|
||||
|
||||
inf_rejoin -.-> inf_interim
|
||||
inf_interim --> inf_oral
|
||||
inf_oral --> inf_decision
|
||||
inf_decision -- "1mo (RoP.151)" --> inf_costs
|
||||
|
||||
classDef root fill:#c6f41c,stroke:#000,stroke-width:2px,color:#000
|
||||
```
|
||||
|
||||
**Anatomy of the multi-parent into `inf.rejoinder`:**
|
||||
|
||||
```sql
|
||||
-- Edge from no-CCR Reply → Rejoinder (1 month, RoP.029.c)
|
||||
INSERT INTO paliad.proceeding_event_edges
|
||||
(proceeding_def_id, from_event_id, to_event_id,
|
||||
duration_value, duration_unit, rule_code, legal_source,
|
||||
unless_flags)
|
||||
VALUES
|
||||
(:upc_inf, :inf_reply_no, :inf_rejoin,
|
||||
1, 'months', 'RoP.029.c', 'UPC.RoP.029.c',
|
||||
ARRAY['with_ccr']);
|
||||
|
||||
-- Edge from CCR-track Reply → Rejoinder (1 month, RoP.029.d)
|
||||
INSERT INTO paliad.proceeding_event_edges
|
||||
(proceeding_def_id, from_event_id, to_event_id,
|
||||
duration_value, duration_unit, rule_code, legal_source,
|
||||
if_flags)
|
||||
VALUES
|
||||
(:upc_inf, :inf_reply_w, :inf_rejoin,
|
||||
1, 'months', 'RoP.029.d', 'UPC.RoP.029.d',
|
||||
ARRAY['with_ccr']);
|
||||
```
|
||||
|
||||
The current encoding (one rule with `condition_flag=['with_ccr']` swapping `alt_duration_value=2`) is rewritten as two structurally-clean sibling edges. The calculator's logic simplifies: pick the edge whose `if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`. No special-cased `alt_*` swap path.
|
||||
|
||||
### 3.9 Five more proceedings spec'd at the DAG-shape level
|
||||
|
||||
For each, the **node count** is shown along with the **distinguishing edge feature** that the new model handles cleanly. Full graphs are out of scope for the design doc — the coder shift will port migrations 008/009/012/041–046 row-by-row.
|
||||
|
||||
| Proceeding | Court system | Nodes | Distinguishing edge feature |
|
||||
|---|---|---:|---|
|
||||
| **UPC infringement action** (UPC_INF, §3.8) | UPC-LD / UPC-CD | ~15 | Multi-parent into `inf.rejoinder`; `if_flags`/`unless_flags` carve the with-CCR / no-CCR tracks; `requires_event_id` gates `inf.defence_to_amend` on actual filing of `inf.app_to_amend`. |
|
||||
| **UPC standalone revocation** (UPC_REV) | UPC-CD | ~15 | TWO independent flags (`with_amend`, `with_cci`) gate the App-to-amend cycle and the Counterclaim-for-Infringement sub-track respectively. Each flag ⇒ ~4 sibling edges activate. Today this is encoded as 8 rules each tagged with one or both flags; tomorrow as edges into a clearly-labelled second-track sub-graph. |
|
||||
| **EPO opposition** (EPO_OPP) | EPA | ~8 | Root edge from the "Decision to grant EP" external trigger anchors `epo_opp.notice` (9-month opposition period, Art.99 EPC). Subsequent edges (R.79, R.116) are unconditional. Rule data flat — no flag conditions. |
|
||||
| **DE LG patent action** (DE_INF_LG) | DE-LG | ~9 | Root edge anchors on `klage.einreichung`. The two-step `Verteidigungsanzeige` (§276.1, 2 weeks) followed by `Klageerwiderung` (§276.1.S2, court-set, ≥2 weeks) is two sequential edges, no flag. The **§ 276 deadline regime** maps cleanly to `requires_event_id` if a future feature wants to gate Klageerwiderung on whether Verteidigungsanzeige was timely filed. |
|
||||
| **DE LG → OLG appeal** (DE_INF_OLG) | DE-OLG | ~7 | Synthetic root node `olg.zustellung_urteil` (party='both', is_root=true) anchors on the LG decision date — bridging the cross-proceeding decision-to-appeal link as a project-tree spawn (§3.6). Berufung 1mo (§517 ZPO), Berufungsbegründung 2mo from filing-of-Berufung (§520.2) — multi-parent edge candidate if the user's date overrides. |
|
||||
| **DPMA → BPatG Beschwerde** (DPMA_BPATG_BESCHWERDE) | DE-BPatG | ~5 | Two sibling edges from `dpma.beschluss` to `bpatg.beschwerde`: 1mo standard (§73 PatG), 2mo if `if_flags=['ausland']` (foreign-resident extension). The flag-conditioned variant is 100% naturally an edge condition, no `alt_*` plumbing needed. |
|
||||
| **EPA Beschwerde (Boards of Appeal)** (EPO_APP) | EPA | ~6 | Root node `epo.entsch` anchors a 2-month notice + 4-month grounds chain (Art.108 EPC). The R.106 RPBA Petition for Review fires as a sibling edge with `if_flags=['fundamental_defect']` — clean. |
|
||||
|
||||
The edge model collapses all the today's flag/swap encodings into "edges with predicates," which is genuinely simpler to reason about and AI-friendly (each edge is a self-contained legal fact: from-X-to-Y-in-D-units-iff-conditions).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration path
|
||||
|
||||
### 4.1 Strategy: additive build → cutover per surface, one boot
|
||||
|
||||
**NOT** a graph-on-top. The Q3+Q5 locks (separate edges table, drop concept table) are structural — keeping `deadline_rules` AND `proceeding_event_types` AND `proceeding_event_edges` AND `deadline_concepts` simultaneously is the worst of both worlds (more layers, no clarity). The migration is genuinely additive build → cutover.
|
||||
|
||||
**NOT** a destructive cutover in one big migration. The 26 production deadlines, the running Fristenrechner, the deadline-search mat-view, and the currently-shipping t-paliad-138 approval flow are all live. We need every one of them to work mid-migration.
|
||||
|
||||
**The right shape:** four migrations, four boots, one feature cutover per boot. The prior table stays till the end, then drops.
|
||||
|
||||
### 4.2 Phase M1 — additive build (one boot, zero behaviour change)
|
||||
|
||||
Single migration. Creates new tables, populates from old, leaves old in place. Fristenrechner + deadline-search keep using the old tables; `paliad.deadlines` keeps `rule_id` pointing to `deadline_rules`. Day-1 deploy = no user-visible change.
|
||||
|
||||
```
|
||||
1. CREATE paliad.court_types + seed 11 rows + FK from paliad.courts.court_type.
|
||||
2. CREATE paliad.proceeding_definitions; backfill from paliad.proceeding_types
|
||||
(rows that survive — drop the obsolete legacy 6 INF/REV/CCR/APM/APP/AMD,
|
||||
keep only the 16 active fristenrechner sets + ZPO_CIVIL).
|
||||
3. CREATE paliad.proceeding_event_types; backfill from deadline_rules
|
||||
(one row per surviving rule), with concept_slug + concept_de + concept_en
|
||||
+ aliases denormalised from deadline_concepts via the concept_id FK.
|
||||
4. CREATE paliad.proceeding_event_edges; backfill:
|
||||
- parent_id ⇒ from_event_id (or NULL when parent_id IS NULL).
|
||||
- condition_flag ⇒ if_flags ([] when NULL).
|
||||
- condition_rule_id ⇒ requires_event_id (the 2 legacy rules).
|
||||
- alt_duration_value/_unit/_rule_code present:
|
||||
emit a SIBLING edge (the alt path) instead of an alt_* column on
|
||||
the same edge. The 4 rules with alt_* split into 8 rows.
|
||||
- is_spawn=true rules ⇒ DO NOT migrate the cross-proceeding parent_id;
|
||||
leave as orphaned root edges in the destination proceeding_def
|
||||
(these are the §3.6 retirement candidates; flag them for the
|
||||
project-tree-spawn UX in Phase M3).
|
||||
5. ALTER paliad.projects ADD proceeding_def_id, court_id,
|
||||
proceeding_trigger_date, proceeding_status. Backfill via the existing
|
||||
proceeding_type_id integer + courts string-match heuristic (§3.5).
|
||||
6. KEEP everything else: deadline_rules, deadline_concepts, trigger_events,
|
||||
event_deadlines, event_categories — all stay, all readable.
|
||||
```
|
||||
|
||||
**Test gate:** server boots, `/tools/fristenrechner` works (still on old tables), `/deadlines/new` works, `/api/projects/{id}` carries the new project columns (NULL on legacy rows is OK), no user-visible change. Run smoke 6/6 (per t-paliad-088 pattern, see memory `35a08abd`).
|
||||
|
||||
### 4.3 Phase M2 — calculator cutover (one boot, behaviour swap)
|
||||
|
||||
Switch `internal/services/fristenrechner.go` from `deadline_rules` to `proceeding_event_types` + `proceeding_event_edges`. The walk algorithm changes:
|
||||
|
||||
| Today | Tomorrow |
|
||||
|---|---|
|
||||
| Walk parent_id chain from a root rule, anchor on triggerDate at root, descend, apply condition_flag gates and alt_* swaps. | BFS from root edge (from_event_id IS NULL) of the proceeding, anchor on triggerDate, for each node enumerate inbound edges, filter by predicates (`if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`), pick the edge that fires (LATEST candidate when multiple), compute due_date, recurse. |
|
||||
| `isCourtDeterminedRule(r)` discriminator. | Same predicate, lifted to the node (`kind IN ('hearing','decision','order') OR party='court'`). |
|
||||
| Composite max/min via `event_deadline.combine_op`. | Same column on the edge. |
|
||||
| `anchor_alt='priority_date'` on EP_GRANT publish. | Folded into a per-proceeding-def "anchor_options" enum — Phase M3 problem, NOT M2. EP_GRANT publish stays specially-handled in Go for one boot. |
|
||||
|
||||
**Switch the trigger calculator** (`event_deadline_service.go`) at the same time. The `trigger_events` (110) + `event_deadlines` (77) data folds into the new shape:
|
||||
- Each `trigger_event` becomes a node (concept_slug from the existing slug column).
|
||||
- Each `event_deadline` becomes a node + an edge from the trigger node to it.
|
||||
- `event_deadline_rule_codes` (72 RoP citations, multiple per deadline) — the new shape only carries ONE `rule_code` per edge. Per row, pick `sort_order=0` as the canonical citation; remaining 0-2 codes per edge become a separate `paliad.proceeding_event_edge_alt_codes` (loose-linkage table) — out of scope for this design but flagged.
|
||||
|
||||
**Search service** (`internal/services/deadline_search_service.go`): rebuild `paliad.deadline_search` mat-view to read from the new tables. The kind discriminator (`'rule'`|`'trigger'`) collapses — every row is a `(node, edge_in)` pair now. UI ranks unchanged.
|
||||
|
||||
**Test gate:** Full Playwright smoke walk through the 16 modern proceedings + the trigger-search Pathway-B flow + the with-CCR flag toggle. Recompute spot-check vs t-paliad-086/111 golden results (Klageerwiderung 2026-04-30 → 2026-08-31, etc). If a Frist drifts more than ±1 day across the migration boundary, BLOCK.
|
||||
|
||||
### 4.4 Phase M3 — instance-side cutover (one boot)
|
||||
|
||||
`paliad.deadlines.rule_id` re-points: today it FKs `deadline_rules.id`; tomorrow it should FK to a tuple (event_type_id, edge_id) — but we can't easily express a 2-column FK. Two options:
|
||||
|
||||
- **Option A** (chosen): `deadlines.rule_id` retired entirely. The legal citation already lives in `deadlines.rule_code text` (per t-paliad-111). The structural pointer becomes `deadlines.event_type_id uuid REFERENCES proceeding_event_types(id)` — node-level, since the edge is an implementation detail. The set of edges that *led* to this Frist is recoverable on read by walking edges-into-this-event-type-of-the-proceeding-instance.
|
||||
- **Option B** (rejected): Keep both rule_id (NULL during transition) AND event_type_id. Adds a deprecation column for unclear value. Skip.
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN event_type_id uuid REFERENCES paliad.proceeding_event_types(id);
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET event_type_id = pet.id
|
||||
FROM paliad.proceeding_event_types pet
|
||||
WHERE pet.code IN (SELECT dr.code FROM paliad.deadline_rules dr WHERE dr.id = d.rule_id)
|
||||
AND pet.proceeding_def_id = (
|
||||
SELECT pd.id FROM paliad.proceeding_definitions pd
|
||||
JOIN paliad.proceeding_types pt ON pt.code = pd.code
|
||||
WHERE pt.id = (SELECT dr.proceeding_type_id FROM paliad.deadline_rules dr
|
||||
WHERE dr.id = d.rule_id)
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.deadlines DROP COLUMN rule_id;
|
||||
-- Keep deadlines.rule_code text — it's user-visible and stable.
|
||||
```
|
||||
|
||||
The 26 production deadlines need spot-check; a stale `rule_code` value (e.g. 'RoP.023') survives untouched, and the new `event_type_id` re-anchors the structural reference.
|
||||
|
||||
**Project-tree spawn UX** (deferred from §3.6's cross-proceeding-edge retirement): the old `is_spawn`-flagged rules in INF/REV/CCR (e.g. `inf.appeal`) had a one-click "create the next proceeding" affordance via the appeal Frist's spawning. Replace with: at `inf.decision` event-type detail page, show "Spawn Berufung sub-project" button → creates a new `verfahren` project under the same parent with `proceeding_def_id=DE_INF_OLG` and `proceeding_trigger_date` defaulting to the decision date. The graph stays clean; the spawn happens at the project tree, with one explicit click.
|
||||
|
||||
### 4.5 Phase M4 — drop legacy (one boot, no behaviour change)
|
||||
|
||||
```sql
|
||||
DROP MATERIALIZED VIEW paliad.deadline_search; -- recreated in M2 against new tables
|
||||
DROP TABLE paliad.event_deadline_rule_codes;
|
||||
DROP TABLE paliad.event_deadlines;
|
||||
DROP TABLE paliad.trigger_events;
|
||||
DROP TABLE paliad.deadline_rules; -- 172 rows gone
|
||||
DROP TABLE paliad.deadline_concepts; -- 57 rows gone
|
||||
DROP TABLE paliad.proceeding_types; -- 26 rows gone
|
||||
|
||||
ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
|
||||
ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
|
||||
-- Keep projects.country (used by holiday lookup as a fallback).
|
||||
```
|
||||
|
||||
After Phase M4 the schema is the locked target. Total elapsed: 4 migrations, 4 boots. Each boot is reversible up to the M4 drop (which IS destructive).
|
||||
|
||||
### 4.6 What about feynman's in-flight branch?
|
||||
|
||||
feynman is currently writing migrations on `mai/feynman/fristenrechner` for t-paliad-157. This consultant analysis is upstream of his implementation and **does NOT** change feynman's brief — he ships what's specified there. After feynman lands, this design's M1 migration starts on top of his work; the proceeding_event_types backfill SELECTs from whatever shape `deadline_rules` is in at that point. No coordination required beyond "M1 picks up from feynman's HEAD."
|
||||
|
||||
Branch hygiene: nothing committed in `mai/einstein/consultant-deadline-data` touches code. Only `docs/design-deadline-data-model-2026-05-08.md` (this file). Merge to main at any time without conflict potential against feynman's branch.
|
||||
|
||||
---
|
||||
|
||||
## 5. AI-friendliness layer
|
||||
|
||||
### 5.1 What's load-bearing for the AI vs decoration
|
||||
|
||||
**Load-bearing:**
|
||||
- `paliad.proceeding_event_types.concept_slug` (e.g. `'statement-of-defence'`) — the LLM's cross-proceeding identity. *"What's the equivalent of a Klageerwiderung in EPO opposition?"* → search proceedings for nodes with `concept_slug='statement-of-defence'` or matching aliases.
|
||||
- `paliad.proceeding_event_types.aliases text[]` — the search vocabulary. Lifts directly from old `deadline_concepts.aliases`. Must remain curated; no user-edit in v1.
|
||||
- `paliad.proceeding_event_types.name_de` + `name_en` — primary surface labels.
|
||||
- `paliad.proceeding_event_edges.rule_code` + `legal_source` — citation grounding.
|
||||
- `paliad.proceeding_event_edges.if_flags` / `unless_flags` / `requires_event_id` — the AI can reason about "this edge fires only if user has flagged with_ccr" without needing to evaluate a JSON expression.
|
||||
|
||||
**Decoration (riding on top):**
|
||||
- `paliad.event_categories` (103 nodes) — the navigation tree. The LLM doesn't need this for legal reasoning; it's a UX scaffold for users who don't know the legal vocabulary. Stays intact, re-FK'd to concept_slug.
|
||||
- `paliad.event_types` (45 rows) — the user-facing instance-side classifier. Operationally useful (filter /deadlines by Type) but not load-bearing for the rule library. Stays unchanged.
|
||||
|
||||
### 5.2 The AI prompt lifecycle
|
||||
|
||||
- **Search:** "what is Klageerwiderung in UPC?" → trigram match on name_de + aliases column → returns `proceeding_event_types` rows where slug='statement-of-defence'. Result card lists: court_type pills (UPC-LD, UPC-CD, DE-LG, EPA), per-context durations + rule_codes via the inbound edges.
|
||||
- **Calculate:** user picks UPC-LD München LD + filing date + flags=['with_ccr']. Service walks the proceeding's edges, filters by predicates, returns a date timeline. AI doesn't need to be in this loop; it's deterministic graph walking.
|
||||
- **Reason:** Paliadin (the LLM-backed assistant) gets fed `proceeding_event_types` + `proceeding_event_edges` for the active proceeding when asked "explain my deadlines" — one self-contained subgraph, ~15-20 nodes, ~20-30 edges per proceeding. Fits comfortably in context.
|
||||
- **Classify:** when the user types "we got hit with a Hinweisbeschluss yesterday" — Paliadin matches against `aliases + name_de` of `proceeding_event_types`, returns the matched concept_slug (`hinweisbeschluss-stellungnahme`), and uses the project's `proceeding_def_id` to find the right node in the right proceeding's graph.
|
||||
|
||||
### 5.3 Where the concept layer's death helps the AI
|
||||
|
||||
Today the LLM has to reason about `deadline_rules.concept_id → deadline_concepts.slug` AND `trigger_events.concept_id (text slug)` AND `event_categories → event_category_concepts.concept_id`. Three different shapes for one identity.
|
||||
|
||||
Tomorrow there's ONE `concept_slug text` column on the proceeding-event-type node and ONE FK in the navigation junction. Same string, same column name, two query paths. Strictly easier for the LLM (and for human contributors).
|
||||
|
||||
### 5.4 Where the concept layer's death costs the AI
|
||||
|
||||
The 57 `deadline_concepts` rows had richer metadata than what survives as columns on the node:
|
||||
- `aliases text[]` — survives.
|
||||
- `description text` — needs to merge into per-node `description text` (already exists, just needs population).
|
||||
- `category` (submission/decision/order/hearing) — survives (`kind` column on node).
|
||||
- `party` — survives (`party` column on node, dominant case).
|
||||
- `sort_order` — survives.
|
||||
|
||||
Net data loss: zero. Net query simplification: substantial.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tradeoffs
|
||||
|
||||
### 6.1 What the migration costs
|
||||
|
||||
- **Engineering effort.** ~2 weeks of coder time across the 4 phases. M1 is a long-evening migration. M2 is the heavy lift (calculator rewrites, ~800 lines in fristenrechner.go + ~300 lines in event_deadline_service.go). M3 is shorter but coordinates with t-paliad-138 approval flow + CalDAV sync (the rule_id drop touches every code path that currently joins to deadline_rules — `internal/services/deadline_service.go`, `internal/services/event_service.go`, `internal/services/agenda_service.go`, `internal/handlers/deadlines.go`, plus the frontend).
|
||||
- **Migration complexity.** 4 boots, each with a smoke gate. The M2 boot is the riskiest — calculator semantics are user-visible and date-precision-sensitive. Need a pre-cutover golden-set test (run BOTH calculators across the 16 active proceedings + 30+ trigger events for a representative trigger date, diff the outputs, fail if any non-trivial drift). t-paliad-086 PR-3 found a 60-day cap bug only because of the SoD-on-2026-04-30-lands-on-Saturday smoke; we'd need similar care here.
|
||||
- **Fristenrechner UX disruption.** None expected. The Pathway-B navigation, the Verfahrensablauf timeline view, the search bar — all read paths can be preserved exactly because the underlying data shape is the same legal facts in different storage. The only user-visible change is at the spawning moment (§4.4 Phase M3 project-tree spawn instead of in-line appeal Frist).
|
||||
- **Documentation churn.** docs/audit-fristenrechner-completeness-2026-04-30.md, docs/plans/unified-fristenrechner.md (cronus), docs/plans/unified-fristenrechner-v3.md (cronus) — all reference the old table names. These are historical (cronus retired from paliad per memory `cc28a8ad`) so they don't need active maintenance, but new contributors will read them and get confused. Add a header to each pointing to this design doc as the structural-truth update.
|
||||
|
||||
### 6.2 What the migration buys
|
||||
|
||||
- **One rule library, not two.** No more deadline_rules + trigger_events drift. No more "did t-paliad-086 fix this in both?" The federated mat-view goes away. Search, calc, and AI all read the same shape.
|
||||
- **Multi-parent edges become natural.** The CCR cross-flow that took t-paliad-131 Phase B1 a full PR + 7 new rules + condition_flag wiring becomes 7 sibling edges with disjoint `if_flags`. Same semantics, half the schema awareness needed.
|
||||
- **Court system axis is queryable.** `SELECT * FROM proceeding_event_edges e JOIN proceeding_event_types et ON et.id = e.to_event_id JOIN proceeding_definitions pd ON pd.id = et.proceeding_def_id WHERE pd.court_type='UPC-LD' AND e.if_flags @> ARRAY['with_ccr']` answers a real question that today requires walking three tables and string-matching.
|
||||
- **The graph fits in an LLM prompt.** ~15 nodes + 25 edges per proceeding, with concept tags + rule codes + party + condition flags inline. No federation, no slug-walking. Paliadin gets a tighter context.
|
||||
- **Conditions are typed, not stringly.** `if_flags text[]` + `unless_flags text[]` + `requires_event_id uuid` — the schema documents itself. Today's `condition_rule_id` + `condition_flag` mix is two pages of code-comment to explain.
|
||||
- **Court_type FK eliminates the holiday-lookup hardcoding.** holidays.go's per-court mapping becomes a JOIN: `courts.country` + `court_types.regime` directly produce the holiday set.
|
||||
- **Extensibility for future condition kinds without further migrations.** New typed columns can be added incrementally (e.g. `min_business_days int` for "Notfrist-only" rules); the JSON-DSL alternative would have meant version-bumping the expression evaluator each time.
|
||||
|
||||
### 6.3 Genuine cost: the column-based condition model breaks down at OR-of-3
|
||||
|
||||
Per Q4 the cost flagged on the option preview: a flag combination like "fires if (with_ccr AND with_amend) OR (without_ccr AND with_cci)" needs TWO sibling edges (one per branch). For OR-of-3-or-more disjoint branches the table has N edges fanning into the same target. This is OK at today's scale (the most complex rule has 2 flags) but if procedural complexity escalates we'd want to revisit. The natural escape valve is to add a JSON `condition` column **alongside** the typed columns later, evaluated only when present — but that's a future decision, not today's.
|
||||
|
||||
### 6.4 What we deliberately don't solve
|
||||
|
||||
- **Cross-proceeding edges** (G7 plus old `is_spawn`). Modelled as project-tree spawns instead. Defer until users complain. (A `proceeding_event_edges.cross_to_proceeding_def_id uuid` column would re-open the model, but it muddies the closed-graph invariant. Skip.)
|
||||
- **Track-switching at proceeding level** (G7 ascended). "If with_ccr, the WHOLE proceeding follows alternate sub-graph rooted at node X." Not modelled — instead, every edge in the alternate sub-graph carries `if_flags=['with_ccr']`. Verbose but explicit. If the verbosity becomes painful (more flag-conditional sub-graphs in DE_INF_BGH cross-appeals?) revisit with a `paliad.proceeding_tracks` overlay table that groups edges into named tracks.
|
||||
- **First-class `paliad.proceedings` instance row.** Per Q2 lock — project IS the instance. If a future feature needs richer instance state (current-stage event_type_id, paused_at, last_advanced_event), columns extend `paliad.projects` directly. If that bloats the projects table beyond comfort, a separate `paliad.project_proceeding_state` 1:1 side-table is the right surgery — but not today.
|
||||
- **Schema RLS on the rule library.** Today `paliad.deadline_rules` is reference data, readable to any authenticated user, writable only via migrations. The new tables inherit that posture (no RLS, service-role-only writes). If a future world has firm-private overrides (HLC's house policy on a Frist), revisit.
|
||||
- **Generic event-types beyond procedural** (contract renewal, IP renewal). These live in `paliad.event_types` (the instance-side classifier). They will not become `proceeding_event_types` rows because they don't belong to a proceeding-DAG. Two layers, two purposes — explicitly OK.
|
||||
|
||||
### 6.5 What if m wanted to go bigger — what's the ceiling
|
||||
|
||||
The locked design is *appropriately ambitious* — addresses every gap in §2 except G7 (track-switching, deferred per §6.4). A more-ambitious target shape would:
|
||||
|
||||
- Make instance state first-class (`paliad.proceedings` table, real timeline log). **Skipped per Q2.**
|
||||
- Make conditions a typed expression DSL. **Skipped per Q4.**
|
||||
- Allow proceeding inheritance / template specialisation (e.g. UPC_INF_with_pi extends UPC_INF, adds 4 nodes). **Not asked for.**
|
||||
- Allow cross-court-system cascades (a UPC LD decision triggers the CoA appeal). **Skipped per §3.6.**
|
||||
|
||||
Each of those would be a follow-up design with its own dogma session. None blocks shipping the current design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open follow-ups for the coder shift
|
||||
|
||||
When m greenlights this design and a coder picks up implementation, surface these explicitly so they don't slip:
|
||||
|
||||
1. **Concept slug curation.** The 57 → ~57 mapping is mostly mechanical. ~5 cases need legal eyes: cross-cutting concepts (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) where the slug exists but doesn't yet sit on a proceeding-specific node. Resolution: emit a new `proceeding_event_types` row in EACH proceeding where the cross-cutting Frist applies, all sharing the same `concept_slug`. Multiplies the row count by ~10 per cross-cutter, fine.
|
||||
2. **Legacy proceeding_types pruning.** The 6 unused legacy codes (`INF`,`REV`,`CCR`,`APM`,`APP`,`AMD`,`ZPO_CIVIL`) and their 36+4=40 dead rules should NOT migrate to `proceeding_definitions`. Confirm with m before dropping (they may have been kept "in case"). If yes-drop: M1 SELECT only the active 16 + ZPO_CIVIL (if still desired).
|
||||
3. **Frontend impact assessment.** Pathway-B decision-tree (t-paliad-133 Phase D-1, in production) reads from `event_categories` + `event_category_concepts` joined to `deadline_concepts`. The junction's concept-side rewires from FK to text. Frontend code that fetches concept_slug stays — backend just speaks the same column under a new FK target.
|
||||
4. **Approval flow integration.** t-paliad-138 dual-control approvals (migration 054) wraps deadline mutations with `approval_requests`. The new `deadlines.event_type_id` column needs to flow through `payload jsonb` correctly; today the approval pre-image captures `rule_id`. M3 swap touches approval_service.go + ApprovalRequest payload schema.
|
||||
5. **CalDAV round-trip.** `paliad.deadlines.caldav_uid` + `caldav_etag` survives. The CalDAV title rendering uses `rule_code` (already free-text) — no behavioural change.
|
||||
6. **Holiday-lookup simplification.** `internal/services/holidays.go` today carries a hardcoded map "courts → applicable holiday sets." After M1 (with `paliad.courts.court_type` FK'd) this becomes a JOIN. Refactor as part of M2 or as a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommendation summary
|
||||
|
||||
**Ship the design.** It addresses every structural gap m's framing exposed (G1–G6, deferring G7 explicitly), it lands on locked decisions throughout (Q1–Q5 verbatim from the AskUserQuestion pass), and it costs ~2 weeks of focused coder time across 4 boots with smoke-gates between.
|
||||
|
||||
**Sequence:** wait for feynman's `mai/feynman/fristenrechner` to land (parallel work, doesn't block this design but does affect the M1 backfill source). Then route Phase M1 to a coder fluent in pgvector + ltree contexts (noether or fritz; cronus excluded per memory `cc28a8ad`). Phase M2 needs Fristenrechner-deep context — same picker. Phase M3 + M4 mechanical, any coder.
|
||||
|
||||
**Recommend:** open one Gitea tracking issue for each phase under m/paliad, link to this design doc by anchor (`#42-phase-m1-additive-build`), set them as a 4-step task chain. Mark M4 as gated on M2 + M3 living in production for ≥1 week without rollback.
|
||||
|
||||
The right outcome of this design isn't a one-shot 6-week refactor. It's four 3-day-class migrations stretched over 2–3 weeks, each individually shippable, each individually reversible until the M4 drop. That's how the existing paliad rule-library got built (migrations 003 → 062, ~6 month accretion); that's how it should be reshaped.
|
||||
|
||||
---
|
||||
|
||||
*End of design doc. ~600 lines target — landing at ~750 with code blocks. NO migration files, NO code edits in this branch — only this design doc per the consultant-mode hard rule.*
|
||||
1015
docs/design-hierarchy-aggregation-2026-05-06.md
Normal file
1015
docs/design-hierarchy-aggregation-2026-05-06.md
Normal file
File diff suppressed because it is too large
Load Diff
947
docs/design-local-chat-2026-05-07.md
Normal file
947
docs/design-local-chat-2026-05-07.md
Normal file
@@ -0,0 +1,947 @@
|
||||
# Design: Local Chat for Teams (t-paliad-145)
|
||||
|
||||
**Status:** READY FOR REVIEW
|
||||
**Author:** noether (inventor)
|
||||
**Issue:** [m/paliad#8](https://mgit.msbls.de/m/paliad/issues/8)
|
||||
**Date:** 2026-05-07
|
||||
**Branch:** `mai/noether/inventor-local-chat-for`
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
A new chat surface inside paliad: **per-project threads + 1:1/small-group DMs**, with @mentions and entity references (`#frist-…`, `#projekt-…`, `#approval-…`). Real-time delivery via **SSE** (one new long-lived endpoint). New schema: `paliad.chat_threads` + `paliad.chat_messages` + `paliad.chat_reads` + `paliad.chat_thread_participants` + `paliad.chat_mentions`. Visibility composes the existing `paliad.can_see_project` predicate; write-access adds a `chat_access` flag on `project_teams` (default ON for internal roles, OFF for `local_counsel`/`expert`); `observer` is read-only.
|
||||
|
||||
In-app badge in the sidebar (alongside the existing approvals bell). **No PWA push, no email digest, no attachments, no search-across-threads in v1** — all deferred to Phase 2. Markdown subset (bold, italic, code, lists, links, blockquote — no headings, no images). Edit window 5 min by author; soft-delete by author or admin. System auto-post into project chat when an approval is requested on that project (the only auto-event in v1).
|
||||
|
||||
Total scope: one migration (`057_chat`), one new service (`ChatService`) + an in-process pubsub (`ChatBus` interface — pg_notify implementation later when paliad multi-replicas), eight HTTP endpoints, one new top-level page `/chat`, one new `/projects/{id}` tab. Estimated ~3500–4500 LoC for the bundled v1 ship; phasable into 3 PRs (schema + service core, real-time + frontend, mentions + auto-post).
|
||||
|
||||
**Trade-off flagged up-front (read §9.1 before approving):** chat-in-paliad collides with HLC's existing internal comms (Slack/Teams/WhatsApp). Compliance is the cited differentiator, but adoption depends on whether team members actually move "Anna, kannst du auf meine Frist 16.05. drauf schauen?" from WhatsApp into paliad. Recommend m sanity-check this with two PA colleagues before locking the v1 scope.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-07)
|
||||
|
||||
Before designing on top, I verified each load-bearing claim against the running system rather than CLAUDE.md / memory:
|
||||
|
||||
| Claim | Source | Verification |
|
||||
|---|---|---|
|
||||
| `paliad.notifications` does not exist | issue body Q5/§References | `information_schema.tables WHERE table_schema='paliad'` — confirmed absent. Only `paliad.reminder_log` (email dedup). |
|
||||
| Service worker is cache-only (no push handler) | brand expectation | `frontend/public/sw.js` — only `install`/`activate`/`fetch` handlers. No `push`, no VAPID keys, no `web-push` Go dep. |
|
||||
| Supabase realtime is not enabled on this Postgres | infra | `pg_extension WHERE extname='supabase_realtime'` → empty. Adding it is a separate decision (changes paliad's Postgres surface). |
|
||||
| `paliad.can_see_project()` already extended for derivation | t-139 Phase 2 | Migration 055 added the partner-unit branch; visibility predicate is the canonical entry. |
|
||||
| `project_teams.role` enum is `{lead, of_counsel, associate, senior_pa, pa, local_counsel, expert, observer}` | t-138 + t-139 | `pg_constraint` on `project_teams_role_check`. Confirms eight values; `observer` is the read-only one. |
|
||||
| Sidebar has a bell badge `id="sidebar-inbox-badge"` for approvals | t-138 | `frontend/src/components/Sidebar.tsx:118`. Same pattern reused for chat unread badge. |
|
||||
| BottomNav has exactly 5 slots (Start / Projekte / + / Agenda / Menü) | mobile UX constraint | `frontend/src/components/BottomNav.tsx`. Adding chat to bottom-nav would need swap-out — deferred to per-project tab on mobile. |
|
||||
| Migration tracker is at version 56 (`056_user_views`) | t-144 A1 | `paliad_schema_migrations` row. Next migration is **057**. |
|
||||
| `paliad.notes` exists as annotations on deadlines/appointments/project_events | data model v2 | Different concept from chat (annotations, not conversation). Document the distinction so they don't collide. |
|
||||
| Single web replica today on Dokploy | docker-compose.yml | One `web` service, no horizontal scaling. SSE in-process bus is safe v1; document multi-replica migration path. |
|
||||
| `feature-roadmap.md` mentions "AI chat" | feature-roadmap.md | This is a different concept (Claude-grounded RAG over paliad content, blocked by no-Anthropic-API decision). Reserve `/chat` for human-to-human; AI chat goes elsewhere if it ever ships (`/ask`, `/assist`, etc.). |
|
||||
|
||||
**Doc-vs-live conflicts encountered:** none material. CLAUDE.md and memory are consistent with the live substrate for this task.
|
||||
|
||||
---
|
||||
|
||||
## §2 What v1 is and what it isn't
|
||||
|
||||
### 2.1 In scope (v1)
|
||||
|
||||
- **Per-project threads**, one chat per project node. Visible to: same set as `can_see_project()` (direct + ancestor + derived). One thread auto-provisioned on first access.
|
||||
- **Direct messages (DMs)**: 1:1 + small-group ad-hoc. Recipient picker pulls from any user the caller can already see (i.e. someone who shares a visible project).
|
||||
- **Plain-text + Markdown subset** (bold, italic, code inline + block, bullet/numbered lists, blockquote, auto-linked URLs). No headings, no images, no inline HTML.
|
||||
- **@mentions** and **entity references** (`#frist-<short_id>`, `#projekt-<slug>`, `#termin-<short_id>`, `#approval-<short_id>`).
|
||||
- **Edit** within 5 min, by author. Tombstone-style **delete** by author or admin.
|
||||
- **Real-time delivery** via SSE.
|
||||
- **In-app sidebar badge** with unread count.
|
||||
- **Read marker** per (user, thread).
|
||||
- **System auto-post**: when an approval request is created on this project's deadlines/appointments, system message in chat ("Anna hat Genehmigung angefordert: …"). One auto-event class only.
|
||||
- **Chat tab on `/projects/{id}`** (deep-link entry).
|
||||
- **Top-level `/chat` page** (global view + DM landing).
|
||||
|
||||
### 2.2 Out of scope (v1, deferred)
|
||||
|
||||
| Feature | Why deferred | Phase |
|
||||
|---|---|---|
|
||||
| PWA push notifications | Needs VAPID + push subscription endpoint + SW push handler. Non-trivial; chat MVP works without it. | 2 |
|
||||
| Email digest of unread chats | Reminder system already saturates email; digest math + SMTP load. | 2 |
|
||||
| File attachments | `paliad.documents` already exists as the canonical document store; chat reuse is a Phase 2 plumbing exercise. | 2 |
|
||||
| Cross-thread search | Postgres FTS + visibility join is a separate optimisation. v1 has thread-scoped LIKE search. | 2 |
|
||||
| Per-deadline / per-termin micro-threads | High-noise risk. Project chat with `#frist-…` references covers most uses. | 3 |
|
||||
| Partner-unit room ("cross-cutting team room") | Semantically maps to a partner_unit-scoped chat; v2 once project chat usage validates. | 3 |
|
||||
| Reactions (👍 / 👎) | Issue body lists this as Phase 2. | 2 |
|
||||
| Threaded sub-replies (Slack-style) | UX complexity + count-math change. Flat threads for v1. | 3 |
|
||||
| End-to-end encryption | HLC's storage assumptions are server-trusted; defer. | — |
|
||||
| External-firm chat (opposing counsel etc.) | Compliance + identity boundary. Out of scope, possibly forever. | — |
|
||||
|
||||
---
|
||||
|
||||
## §3 Sub-design A — Surface set, visibility, permissions
|
||||
|
||||
Answers Q1, Q2, Q3, Q19, Q20.
|
||||
|
||||
### 3.1 Surface set (Q1)
|
||||
|
||||
**Recommendation: project chat + DMs in v1. Defer per-deadline/per-termin/partner-unit/topical.**
|
||||
|
||||
| Surface | v1? | Rationale |
|
||||
|---|---|---|
|
||||
| Per-project | ✅ | Already-resolved team set, contextual references, replaces "@channel"-style coordination on a project. The high-leverage default. |
|
||||
| DM (1:1) | ✅ | Replaces "schick mir kurz das Aktenzeichen" WhatsApps. Recipient set = anyone the caller can see. |
|
||||
| DM (small group, ad-hoc 3–8) | ✅ | Same plumbing as 1:1 — participants set instead of pair. No project context required. |
|
||||
| Per-deadline | ❌ Phase 3 | High-noise risk; project chat with `#frist-1234` reference does 95% of the same work. Revisit if usage shows demand. |
|
||||
| Per-termin | ❌ Phase 3 | Same reasoning as per-deadline. |
|
||||
| Partner-unit room | ❌ Phase 3 | Maps cleanly onto `partner_units` once we see the surface gain traction. Extra surface for v1 = extra surface to maintain. |
|
||||
| Cross-cutting topical rooms | ❌ Defer | No clear v1 use case; would need user-driven creation + naming + discovery. Wait for organic demand. |
|
||||
|
||||
### 3.2 Visibility model (Q2 — hierarchy)
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Each project node has its own thread.** A `Client` chat is a separate thread from its child `Litigation`, which is separate from each `Patent` and `Case`. Threads are NOT aggregated up the hierarchy.
|
||||
- **Read access per thread** = the existing `paliad.can_see_project(project_id)` predicate (which already includes direct + ancestor team, derived partner-unit members, and global_admin). This means a member added at `Client` level sees the Client thread *and* every descendant's thread (because they can already see those projects' deadlines/appointments). Conversely, a member added only at `Case` level sees only the Case thread.
|
||||
- **Why not aggregate down?** Aggregating ("Client thread = union of all descendant threads") breaks privacy: Case 14-vs-Müller chat content would surface in the Siemens AG Client thread, visible to all Siemens AG team members. Each project-level thread is its own boundary.
|
||||
- **Why use per-thread visibility = `can_see_project`?** It mirrors every other visibility decision in paliad (deadlines, appointments, events, approvals). One predicate, one mental model. If t-139's derivation rules change, chat tracks for free.
|
||||
|
||||
**Practical example:**
|
||||
|
||||
```
|
||||
Client: Siemens AG ← thread S
|
||||
├─ Litigation: UPC München patent X ← thread L1
|
||||
│ ├─ Patent: EP1234567 ← thread P1
|
||||
│ │ └─ Case: 14-vs-Müller ← thread C1
|
||||
│ └─ Patent: EP7654321 ← thread P2
|
||||
└─ Litigation: EPO Opposition ← thread L2
|
||||
```
|
||||
|
||||
A member added at `Client` (Siemens AG) sees S, L1, L2, P1, P2, C1. A member added only at `Case 14-vs-Müller` sees only C1. A derived partner-unit member attached at L1 sees L1, P1, P2, C1.
|
||||
|
||||
**Anti-feature flagged:** no "broadcast to whole subtree" on send. If a lead wants to message everyone on every Siemens AG thread, they post to the `Client`-level thread; sub-thread members do not get cross-posted. This is intentional — broadcast is a separate UX (Issue #7 bulk team email) and shouldn't be smuggled into chat.
|
||||
|
||||
### 3.3 Approval flow integration (Q3 — t-138 cross-cut)
|
||||
|
||||
**Recommendation: chat does NOT replace inbox or email for approval. Instead, on approval-request creation, post a system message into the project chat with a deep-link to `/inbox`.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- Approvals are structured (approve/reject buttons, decision_kind, audit). Chat is unstructured. Conflating them dilutes the structure.
|
||||
- But chat is where the team's eyeballs live ambiently. Posting "📌 Anna hat Genehmigung angefordert: Frist 16.05. (Replik einreichen). [Zur Genehmigung →]" surfaces the request without forcing anyone to refresh /inbox.
|
||||
- **De-dup with email + bell:** the system post is informational only. Email reminder + bell badge stay primary signals. If the approver opens the chat first and clicks the deep-link, they reach /inbox; the bell decrements as soon as they act there.
|
||||
|
||||
**Mechanism:**
|
||||
|
||||
- `ApprovalService.Submit*` calls `ChatService.PostSystemMessage(threadID, kind="approval_requested", refs={approval_id})` inside the same tx as the approval row insert. If chat post fails, log + continue (approval is the load-bearing record; chat is observability).
|
||||
- One auto-event class only. NOT every deadline-created / appointment-created. Reason: existing Verlauf already captures those; chat would become a duplicate event log.
|
||||
- System messages render with a distinct chip/style (`.chat-system-message`) — non-author, no edit/delete affordance for users.
|
||||
|
||||
**Anti-feature flagged:** approval *decisions* (approve/reject/revoke) do NOT auto-post. Only the *request* posts. This keeps signal density manageable.
|
||||
|
||||
### 3.4 Who can read + write (Q19, Q20)
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Read**: anyone with `can_see_project` access (direct + ancestor + derived + global_admin). Same predicate as deadlines/appointments. No further gating.
|
||||
- **Write**: same set, minus:
|
||||
- `observer` — always read-only on chat (mirrors observer's read-only contract on deadlines/appointments).
|
||||
- `local_counsel` and `expert` — opt-in per project via a new `project_teams.chat_access` boolean. Default `false` for these two roles, `true` for everyone else. Project lead or global_admin can flip the toggle on `/projects/{id}/settings/team`.
|
||||
|
||||
**Schema delta (in 057):**
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
|
||||
|
||||
UPDATE paliad.project_teams SET chat_access = false
|
||||
WHERE role IN ('local_counsel', 'expert');
|
||||
|
||||
CREATE INDEX project_teams_chat_idx
|
||||
ON paliad.project_teams (project_id, user_id) WHERE chat_access = true;
|
||||
```
|
||||
|
||||
**Why a boolean instead of a separate `chat_access_role` enum?** External counsel/expert participation in chat is binary in practice ("included or excluded"). Granular ladder isn't needed. If product later wants "external can read but not write", we revisit.
|
||||
|
||||
**Why default ON for internal roles?** Path of least surprise: paliad already gives them visibility on all project artifacts; chat read+write is the same trust level.
|
||||
|
||||
**Why default OFF for external?** Compliance is the marquee differentiator m cited. External counsel chatting in paliad creates audit/disclosure surface that internal counsel may not anticipate. Default OFF puts the lead in control.
|
||||
|
||||
**Derived members (partner-unit derivation, t-139)**: read = visibility (yes). Write = also yes by default (they can already see the project's other artifacts; chat is no more privileged). Derived members do NOT need `chat_access=true` — that flag is on `project_teams` only, which derived members don't appear in. The derivation branch in the read query already covers them; for write, the service-layer check is "caller has any access (direct/ancestor/derived/admin) AND if direct, role != observer AND chat_access != false".
|
||||
|
||||
**Service-layer write predicate (Go):**
|
||||
|
||||
```go
|
||||
func (s *ChatService) canPostToProject(ctx context.Context, callerID, projectID uuid.UUID) (bool, error) {
|
||||
// global_admin shortcut
|
||||
if isGlobalAdmin, _ := s.users.IsGlobalAdmin(ctx, callerID); isGlobalAdmin {
|
||||
return true, nil
|
||||
}
|
||||
// Direct/ancestor membership with role != observer AND chat_access = true
|
||||
var directOK bool
|
||||
err := s.db.QueryRowxContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.role <> 'observer'
|
||||
AND pt.chat_access = true
|
||||
AND pt.project_id = ANY(string_to_array((SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
|
||||
)`, callerID, projectID).Scan(&directOK)
|
||||
if err != nil { return false, err }
|
||||
if directOK { return true, nil }
|
||||
// Derived (partner-unit) membership: observer/external-flag not relevant — derivation has no role
|
||||
return s.derivation.IsDerivedMember(ctx, callerID, projectID)
|
||||
}
|
||||
```
|
||||
|
||||
`canRead` is the simpler `can_see_project` mirror — no observer/external gating.
|
||||
|
||||
---
|
||||
|
||||
## §4 Sub-design B — Real-time, content, persistence
|
||||
|
||||
Answers Q4–Q15, Q21.
|
||||
|
||||
### 4.1 Real-time architecture (Q4)
|
||||
|
||||
**Recommendation: Server-Sent Events (SSE).**
|
||||
|
||||
| Option | v1 fit | Notes |
|
||||
|---|---|---|
|
||||
| (a) Polling | ❌ | Cheap to ship but lossy under tab-sleep, doubles the API load on every active tab. Already a pain point for the bell badge. |
|
||||
| (b) **SSE** | ✅ | One-way push, native in Go's `net/http` via `http.Flusher`, EventSource auto-reconnects, single endpoint, no per-message connection. Traefik forwards `text/event-stream` with no special config beyond disabling response buffering. |
|
||||
| (c) WebSockets | Defer | Bidirectional we don't need (post is a regular POST + bus publish). Adds heartbeat/reconnect/sticky-session complexity. Worth it only if v2 surfaces typing-indicators or read-receipts that need bidi. |
|
||||
|
||||
**Endpoint shape:**
|
||||
|
||||
```
|
||||
GET /api/chat/stream
|
||||
Accept: text/event-stream
|
||||
[Last-Event-ID: <message_id>] ← optional for resume
|
||||
```
|
||||
|
||||
Server emits:
|
||||
|
||||
```
|
||||
event: message
|
||||
id: <message_id>
|
||||
data: {"type":"message_created","thread_id":"…","message":{…}}
|
||||
|
||||
event: message
|
||||
id: <message_id>
|
||||
data: {"type":"message_edited","thread_id":"…","message_id":"…","body":"…","edited_at":"…"}
|
||||
|
||||
event: message
|
||||
id: <message_id>
|
||||
data: {"type":"message_deleted","thread_id":"…","message_id":"…"}
|
||||
|
||||
event: read
|
||||
data: {"type":"read_advanced","thread_id":"…","user_id":"…","up_to_message_id":"…"}
|
||||
|
||||
: ping ← every 25s, keeps Traefik from reaping idle stream
|
||||
```
|
||||
|
||||
Server-side: per-user goroutine subscribed to `ChatBus` (see §4.2). On bus event, filter against the user's visibility (cached at connect; invalidate on team-membership change), encode SSE frame, flush. Connection close = unsubscribe.
|
||||
|
||||
**Failure modes + mitigations:**
|
||||
|
||||
| Failure | Mitigation |
|
||||
|---|---|
|
||||
| Idle proxy reaper (Traefik default ~3min) | Heartbeat comment every 25s. |
|
||||
| Backpressure if recipient connection is slow | Per-user channel has a small buffer (16). Overflow drops the slow consumer's connection (client EventSource auto-reconnects with `Last-Event-ID`, replay catches up). |
|
||||
| Multi-replica fanout (future) | Bus interface allows swap to `pgnotify.ChatBus` (LISTEN/NOTIFY on `paliad_chat`) without touching ChatService. |
|
||||
| HTTP/1.1 6-conn-per-origin browser cap | Document. Single tab = no issue. Multi-tab is a known SSE constraint; users with many tabs will see one tab's stream go silent. Rare in legal-team usage; defer. |
|
||||
|
||||
**Disable response compression on this endpoint** in handler (`w.Header().Set("Content-Encoding", "identity")`) to prevent Traefik from buffering.
|
||||
|
||||
### 4.2 Message bus (interface)
|
||||
|
||||
**`internal/services/chat_bus.go`**:
|
||||
|
||||
```go
|
||||
type ChatEvent struct {
|
||||
Kind string // message_created | message_edited | message_deleted | read_advanced
|
||||
ThreadID uuid.UUID
|
||||
MessageID uuid.UUID // for message_* events
|
||||
Payload map[string]any
|
||||
AudienceFn func(uid uuid.UUID) bool // visibility filter — applied per subscriber
|
||||
}
|
||||
|
||||
type ChatBus interface {
|
||||
Publish(ctx context.Context, ev ChatEvent) error
|
||||
Subscribe(ctx context.Context, userID uuid.UUID) (<-chan ChatEvent, func())
|
||||
}
|
||||
|
||||
// Default: in-process. Per-user channel registry under sync.Map.
|
||||
// Future: postgresChatBus uses pg_notify(channel="paliad_chat") for fanout.
|
||||
```
|
||||
|
||||
**Why an interface from day 1?** paliad's deploy is single-replica today (docker-compose, one `web` container on Dokploy). When/if we scale to N, swap in the pg_notify implementation; no callsite changes. Cheap insurance.
|
||||
|
||||
### 4.3 Notification path (Q5)
|
||||
|
||||
**Recommendation: in-app sidebar badge ONLY in v1. Email digest deferred. PWA push deferred.**
|
||||
|
||||
| Channel | v1 | Phase 2 | Phase 3+ |
|
||||
|---|---|---|---|
|
||||
| In-app sidebar Chat unread badge | ✅ | | |
|
||||
| Browser tab title flash on incoming message (foreground tab on chat surface) | ✅ (cheap) | | |
|
||||
| `Notification` API (foreground, opt-in per browser permission) | ✅ (cheap) | | |
|
||||
| Email digest of unread-since-last-login | | ✅ | |
|
||||
| PWA push (background, requires VAPID + SW push handler) | | | ✅ |
|
||||
| CalDAV alarm | ❌ | ❌ | ❌ Wrong channel — calendar is for time-anchored events. |
|
||||
|
||||
**Rationale for deferring PWA push:**
|
||||
|
||||
- paliad's `frontend/public/sw.js` is currently a 90-line cache-only worker. Adding push needs:
|
||||
1. A new `addEventListener('push', …)` and `addEventListener('notificationclick', …)` block.
|
||||
2. VAPID keypair generation + secure storage (env vars).
|
||||
3. New table `paliad.push_subscriptions(user_id, endpoint, p256dh, auth, user_agent, created_at)`.
|
||||
4. Server-side `web-push` Go lib (e.g. `github.com/SherClockHolmes/webpush-go`).
|
||||
5. New endpoint `POST /api/push/subscribe` + permission-prompt UX.
|
||||
- Worth ~600–800 LoC and a separate review cycle. Don't bundle into chat MVP. Once chat usage is validated, push graduates as a Phase 2 task and serves chat + approvals + reminders together (one push pipeline, multiple producers).
|
||||
|
||||
**Rationale for deferring email digest:**
|
||||
|
||||
- Mail volume is already a friction point — t-paliad-064 just collapsed reminders into bundled digests. Layering an unread-chat email on top would re-saturate.
|
||||
- Once usage shows it's needed, the digest can compose with the existing morning/evening slot reminder pipeline.
|
||||
|
||||
### 4.4 Read / unread + delivery state (Q6)
|
||||
|
||||
**Recommendation: per-(user, thread) last-read marker. No per-message read receipts.**
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.chat_reads (
|
||||
user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
thread_id uuid REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
|
||||
last_read_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, thread_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Unread count for sidebar badge:**
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*)
|
||||
FROM paliad.chat_messages m
|
||||
WHERE m.thread_id IN (<visible thread ids for caller>)
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.author_id <> $caller
|
||||
AND (
|
||||
NOT EXISTS (SELECT 1 FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
|
||||
OR m.created_at > (SELECT cr.last_read_at FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
|
||||
);
|
||||
```
|
||||
|
||||
Optionally cap at 99+ in UI.
|
||||
|
||||
**Why no per-message read receipts?** Privacy concern (legal team won't want "Anna saw your message 14 min ago, didn't reply"). UX clutter. Slack made the same call (workspace-default).
|
||||
|
||||
**`last_read_message_id` on `chat_reads`** is for "scroll to the boundary" UX — when you open a thread, the client scrolls to the message immediately above the marker and inserts a "neue Nachrichten" divider. The boundary is sticky until you mark-read again.
|
||||
|
||||
### 4.5 Message body (Q7)
|
||||
|
||||
**Recommendation: stored as Markdown source, rendered with a small whitelisted renderer.**
|
||||
|
||||
| Render | v1 | Notes |
|
||||
|---|---|---|
|
||||
| Bold (`**`/`__`) | ✅ | |
|
||||
| Italic (`*`/`_`) | ✅ | |
|
||||
| Inline code (` `` `) | ✅ | |
|
||||
| Code block (```` ``` ````) | ✅ | Three-backtick fenced; preserves whitespace. |
|
||||
| Bullet list | ✅ | |
|
||||
| Numbered list | ✅ | |
|
||||
| Blockquote (`>`) | ✅ | |
|
||||
| Auto-link URLs | ✅ | `https?://` patterns auto-wrap as anchor with `target=_blank rel=noopener`. |
|
||||
| Headings (`#`) | ❌ | Chat ≠ doc. Strip to plain text on render. |
|
||||
| Images / embeds | ❌ | Use attachments (Phase 2). |
|
||||
| Inline HTML | ❌ | Always sanitised out. |
|
||||
| Raw URLs | ✅ | Auto-link them. |
|
||||
|
||||
**Library choice:** Server-side, use a small custom renderer or `github.com/yuin/goldmark` with a whitelist extension. Either works; I lean toward a tiny custom one (~150 LoC) because the subset is small and goldmark imports a lot. Frontend is render-only — server delivers HTML-rendered + raw source; client picks based on edit/view state.
|
||||
|
||||
**Storage:** raw Markdown source in `paliad.chat_messages.body`. Rendered HTML is computed on read (cheaply; cache in a separate column if benchmarks ever justify). Rendering on read keeps mention/entity-ref resolution dynamic (a deleted deadline's `#frist-…` chip degrades to a dimmed pill instead of a stale link).
|
||||
|
||||
### 4.6 Mentions + entity references (Q8)
|
||||
|
||||
**Recommendation: yes for v1 — `@user` + `#frist-…` + `#projekt-…` + `#termin-…` + `#approval-…`.**
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- **Compose-side**: client-side autocomplete on `@` and `#`. Hits `/api/chat/autocomplete?q=…&context=<thread_id>` — server returns a small list scoped to the thread's visibility (mentions: thread members; entities: project's items + globally-visible items the caller can see).
|
||||
- **Persist-side**: on POST, server parses tokens `@<slug>` / `#<entity>-<short_id>`, resolves to UUIDs, stores in `paliad.chat_mentions` (for users) and in `metadata.entity_refs` JSON (for entities). Original Markdown source preserves the `@anna` / `#frist-1234` syntax.
|
||||
- **Render-side**: on read, server renders tokens as HTML with deep-links: `<a class="chat-mention" href="/team#user-<uuid>">@anna</a>`, `<a class="chat-entity-ref entity-frist" href="/deadlines/<id>">Frist 1234</a>`. Rendering re-checks visibility per recipient — invisible references render as dimmed `<span class="chat-entity-ref dimmed">[#frist-…]</span>`.
|
||||
|
||||
**Notification effect**: a mention drives a unread-count bump. A future Phase 2 enhancement: a separate "Erwähnungen" tab on `/chat` that filters to messages mentioning the caller, with a separate badge. v1 just lifts the unread-count visibility (mention or no mention, the badge ticks).
|
||||
|
||||
### 4.7 Attachments (Q9)
|
||||
|
||||
**Recommendation: out of scope for v1. Reference existing `paliad.documents` via `#dok-<id>` is a v2 pattern.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- `paliad.documents` is the canonical document store with metadata (folder, tags, ACL planned). Adding a parallel attachment surface from chat would create two upload pipelines.
|
||||
- v1 chat references existing documents via entity-ref `#dok-<id>` (deferred until v2 for the implementation; the syntax is reserved now).
|
||||
- v2 attachment flow: drag-and-drop into chat → uploads into `paliad.documents` → message body gains a `#dok-<id>` reference. Single document, two surfaces.
|
||||
|
||||
### 4.8 Edits / deletions (Q10)
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Edit** allowed within 5 min of post. After 5 min, edit affordance is hidden — correct via reply.
|
||||
- **Delete** allowed at any time by author. Soft-delete: `deleted_at` set, body replaced server-side with the rendered tombstone "Diese Nachricht wurde gelöscht." in the API payload (DE/EN per `users.lang`). Client renders the tombstone in muted styling. Mentions/entity-refs in deleted messages are preserved server-side (audit) but suppressed in render.
|
||||
- **Admin override**: global_admin can delete any message at any time. Audit-marked: `metadata.deleted_by_admin = <admin_id>` and a system-message in the same thread "Admin hat eine Nachricht entfernt." (no body content disclosed).
|
||||
|
||||
**Why 5 min?**
|
||||
|
||||
- Long enough for typo undo, short enough to keep audit trust ("the message you just read isn't the one stored an hour later").
|
||||
- Mirrors many legal-team chat tools (Slack's default-edit-window can be configured; Teams has 0 by default but admin can extend).
|
||||
- Edit shows `(bearbeitet)` chip with tooltip showing `edited_at`.
|
||||
|
||||
**Why soft-delete only?** Compliance: paliad may need to demonstrate message provenance even after deletion. Soft-delete keeps the row + author + created_at; only `body` is hidden. Hard-delete is escalation-only (manual SQL by global_admin if legal forces).
|
||||
|
||||
### 4.9 Replies / threading (Q11)
|
||||
|
||||
**Recommendation: flat threads in v1. Slack-style sub-threads deferred.**
|
||||
|
||||
A flat thread means every message in the project chat lands at the bottom, ordered chronologically. To reply to a specific message, quote it (`>` Markdown blockquote) or @mention the author — same pattern Twitter/Mastodon use successfully without sub-threads.
|
||||
|
||||
**Why flat?**
|
||||
|
||||
- Sub-threads add: a `parent_message_id` column, a parent-thread-summary fold-out UI, "thread of threads" navigation, separate unread counts for thread vs sub-thread.
|
||||
- For project-team chat (5–15 active members per project), flat is cleaner. Sub-threads pay off in larger channels (50+ members, parallel conversations).
|
||||
- Re-introduce in v2 if usage shows specific demand for parallel parlay.
|
||||
|
||||
### 4.10 Search (Q12)
|
||||
|
||||
**Recommendation: thread-scoped search in v1. Cross-thread search deferred.**
|
||||
|
||||
- Thread-scoped: `WHERE thread_id = $1 AND body ILIKE '%' || $2 || '%' AND deleted_at IS NULL` — sub-second on threads up to ~10k messages. Above that, add a Postgres FTS index in v2.
|
||||
- Cross-thread: would need `paliad.chat_messages` FTS + visibility join — workable but separable. Defer to Phase 2 once we know the cross-thread use case.
|
||||
|
||||
### 4.11 Storage schema (Q13)
|
||||
|
||||
**Migration 057:**
|
||||
|
||||
```sql
|
||||
-- paliad.chat_threads ----------------------------------------------------------
|
||||
|
||||
CREATE TYPE paliad.chat_thread_kind AS ENUM ('project', 'dm');
|
||||
|
||||
CREATE TABLE paliad.chat_threads (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind paliad.chat_thread_kind NOT NULL,
|
||||
project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
-- DM 1:1 / small group: participants in chat_thread_participants;
|
||||
-- project: visibility predicate (no rows in chat_thread_participants).
|
||||
title text, -- DM small-group: optional user-supplied; project: NULL (use project name)
|
||||
created_by uuid REFERENCES paliad.users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_activity timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chat_thread_kind_consistency CHECK (
|
||||
(kind = 'project' AND project_id IS NOT NULL) OR
|
||||
(kind = 'dm' AND project_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- One project = one thread (idempotent provisioning).
|
||||
CREATE UNIQUE INDEX chat_threads_project_idx ON paliad.chat_threads (project_id) WHERE kind = 'project';
|
||||
|
||||
CREATE INDEX chat_threads_activity_idx ON paliad.chat_threads (last_activity DESC);
|
||||
|
||||
-- paliad.chat_thread_participants ---------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_thread_participants (
|
||||
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
role text NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')),
|
||||
-- 'admin' on a DM = the creator who can add/remove participants. Project chats have no rows here.
|
||||
PRIMARY KEY (thread_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX chat_thread_participants_user_idx ON paliad.chat_thread_participants (user_id);
|
||||
|
||||
-- paliad.chat_messages --------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- author_id NULL = system message (auto-post)
|
||||
body text NOT NULL,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- metadata: { system: true, system_kind: "approval_requested", entity_refs: [...], deleted_by_admin: <uuid>, … }
|
||||
edited_at timestamptz,
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX chat_messages_thread_idx ON paliad.chat_messages (thread_id, created_at DESC);
|
||||
CREATE INDEX chat_messages_author_idx ON paliad.chat_messages (author_id, created_at DESC);
|
||||
|
||||
-- paliad.chat_reads -----------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_reads (
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
|
||||
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
|
||||
last_read_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, thread_id)
|
||||
);
|
||||
|
||||
-- paliad.chat_mentions --------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.chat_mentions (
|
||||
message_id uuid NOT NULL REFERENCES paliad.chat_messages(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX chat_mentions_user_idx ON paliad.chat_mentions (user_id);
|
||||
|
||||
-- paliad.project_teams.chat_access -------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
|
||||
|
||||
UPDATE paliad.project_teams SET chat_access = false
|
||||
WHERE role IN ('local_counsel', 'expert');
|
||||
|
||||
-- RLS ------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.chat_threads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_thread_participants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_reads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.chat_mentions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Service-role bypasses RLS (paliad's pgx pool runs as service-role per t-paliad-088 lesson #2).
|
||||
-- Policies exist for any future direct-DB query path; service-layer is the load-bearing gate.
|
||||
-- (RLS predicates omitted from this design doc; sketch in §13 of impl plan.)
|
||||
```
|
||||
|
||||
**`chat_threads.kind = 'dm'` uniqueness for 1:1**: enforce via service layer (sort participant UUIDs, check existing thread with exact participant set). Not in the schema CHECK because participants live in another table.
|
||||
|
||||
### 4.12 Retention (Q14)
|
||||
|
||||
**Recommendation: forever in v1. Soft-delete only. Phase 2 = export/archive flow.**
|
||||
|
||||
Compliance: HLC may need permanent archival of project-related conversations. Forever-storage is the safest default; cheaper than implementing rolling-window deletion + getting it wrong.
|
||||
|
||||
`deleted_at` is soft-delete by user/admin action; no time-based purge in v1.
|
||||
|
||||
**Phase 2** features for retention/compliance:
|
||||
- Export thread to PDF/JSON (audit trail).
|
||||
- Per-project retention override (e.g. Case closed → archive after 6 months).
|
||||
- Search across archived (read-only) threads.
|
||||
|
||||
### 4.13 Audit / Verlauf integration (Q15)
|
||||
|
||||
**Recommendation: chat does NOT appear in Verlauf by default. Optional "Pin to Verlauf" affordance on individual messages → creates a `note` (Phase 2).**
|
||||
|
||||
Rationale:
|
||||
|
||||
- Verlauf already has 18 distinct `event_type` values (`deadline_*`, `appointment_*`, `checklist_*`, `note_*`, `project_type_changed`). Adding `chat_message_*` events for every chat post would dilute signal — Verlauf should answer "what changed on this matter" not "what was said".
|
||||
- The "Pin to Verlauf" affordance lets users explicitly promote a chat message to a `paliad.note` (and emit a `note_created` event_type — the existing pattern). Phase 2; reserve the UX hook now.
|
||||
|
||||
### 4.14 PWA push (Q21)
|
||||
|
||||
**Recommendation: defer to Phase 2.** See §4.3 above for the cost/value reasoning. v1 ships without push; users get unread badge + tab-flash + foreground `Notification` API.
|
||||
|
||||
---
|
||||
|
||||
## §5 Sub-design C — Integration with existing surfaces
|
||||
|
||||
Answers Q16–Q18.
|
||||
|
||||
### 5.1 Sidebar entry (Q16)
|
||||
|
||||
**Recommendation: BOTH — a top-level `Chat` sidebar entry with global unread badge AND a per-project `Chat` tab on `/projects/{id}` deep-linking the same thread.**
|
||||
|
||||
**Sidebar:**
|
||||
|
||||
```
|
||||
Übersicht
|
||||
├─ Home
|
||||
├─ Dashboard
|
||||
├─ Agenda
|
||||
├─ Inbox (bell badge — approvals)
|
||||
├─ Chat (new — chat badge — unread messages) ← new
|
||||
└─ Team
|
||||
Arbeit
|
||||
└─ …
|
||||
Meine Sichten
|
||||
└─ …
|
||||
…
|
||||
```
|
||||
|
||||
Position: directly under `Inbox`. Same group ("Übersicht"). Same badge pattern (`id="sidebar-chat-badge"`).
|
||||
|
||||
**`/chat` page** (new top-level):
|
||||
- Two-pane layout: thread list left (recently-active first), active thread right (messages + composer).
|
||||
- Thread list shows: project chats the user has access to (sorted by `last_activity DESC`), DMs (sorted by `last_activity DESC`).
|
||||
- Tabs: `Alle` (default), `Projekte`, `DMs`, `Erwähnungen` (Phase 2). Visual style mirrors `/inbox` tab chips.
|
||||
|
||||
**Per-project Chat tab on `/projects/{id}`:**
|
||||
- Adds a new "Chat" tab next to existing tabs (Übersicht / Fristen / Termine / Verlauf / Team / …). Tab opens the same project thread, full-width-in-tab.
|
||||
- Deep-links: `/projects/<id>?tab=chat` and `/projects/<id>/chat` (server resolves both).
|
||||
|
||||
**Mobile (BottomNav)**:
|
||||
- BottomNav slots are full at 5 (Start / Projekte / + / Agenda / Menü). Don't swap a slot — chat surfaces from `Menü` and from per-project `Chat` tab. Defer dedicated mobile slot to Phase 2 once usage justifies.
|
||||
|
||||
### 5.2 Custom Views (#5) integration (Q17)
|
||||
|
||||
**Recommendation: chat messages are NOT a 5th source in the ViewService union.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- ViewService unions four kinds: deadline, appointment, project_event, approval_request. Each is a *time-anchored event* with a structured semantics ("Frist X due on Y"). Chat messages are conversation, not events.
|
||||
- Adding a `chat_message` source would dilute the substrate's purpose and produce noisy Custom Views ("show me everything in the next 30 days" → 80% chat noise).
|
||||
- **Mentions and entity-refs do NOT cross over either.** A `#frist-1234` reference inside chat doesn't promote the chat message into the deadline's audit; the reference is a navigation aid, not an audit fact.
|
||||
|
||||
**Phase 2 escape hatch**: if demand emerges for "show me activity (chat + events) on this project", introduce a new `chat_activity` synthesised source that emits one row per *day-bucket-with-N-messages*, not per-message. That keeps Custom Views unflooded while exposing the "this project has been busy" signal. Reserve the source name; don't implement v1.
|
||||
|
||||
### 5.3 Bulk team email (#7) overlap (Q18)
|
||||
|
||||
**Recommendation: distinct surfaces, deliberate split.**
|
||||
|
||||
| Use case | Bulk email (#7) | Chat |
|
||||
|---|---|---|
|
||||
| Team-wide announcement, no reply expected ("Server-Wartung am Montag 18 Uhr") | ✅ | — |
|
||||
| Coordination on a specific matter ("Anna, kannst du auf meine Frist 16.05. drauf schauen?") | — | ✅ |
|
||||
| Process reminders / quarterly newsletters | ✅ | — |
|
||||
| "Wer sieht heute den 14:00 hearing-call?" | — | ✅ |
|
||||
| External-counsel briefing | ✅ (mail) | — (chat is internal-only by default) |
|
||||
| Hot-fix coordination during litigation prep | — | ✅ |
|
||||
| Birthday / kudos (if product wants it) | — | ✅ |
|
||||
|
||||
**Pattern:**
|
||||
- **Email** is broadcast, archive-friendly, no expectation of synchronous reply, lives in user's regular inbox alongside client mail.
|
||||
- **Chat** is back-and-forth, ambient, threaded, scoped to a project's team or a small DM group.
|
||||
|
||||
The two coexist; users self-select. No automatic cross-posting. If a user writes a chat post that is "really" a broadcast announcement, that's a soft heuristic the product can teach later (Phase 3 nudge: "Looks like a broadcast — send as email instead?").
|
||||
|
||||
---
|
||||
|
||||
## §6 Inventor follow-up questions for m
|
||||
|
||||
Beyond the 21 questions in the issue body, my design surfaced a few I cannot lock without a call:
|
||||
|
||||
| # | Question | Recommendation |
|
||||
|---|---|---|
|
||||
| Q22 | **DM creation policy**: anyone-to-anyone, or scoped to "people I share a project with"? | Recommend: scoped to "people I share at least one visible project with" — keeps DMs inside the matter graph, prevents random cross-firm pings. global_admin always reachable. |
|
||||
| Q23 | **DM small-group cap**: hard limit on DM participants? | Recommend cap at **8** (informal coordination tier; above that, a project chat or partner-unit room is right). Not a hard schema cap; service-layer + UI cap. Lift later if we see demand. |
|
||||
| Q24 | **Project chat auto-provision timing**: lazily on first read, or eagerly on project create? | Recommend lazy. Most projects never get chatter; lazy provisioning saves rows + noise on `/projects` list. Once demand is shown, switch to eager (one-line change). |
|
||||
| Q25 | **System auto-post audience**: project chat post is visible to ALL thread members, including external counsel + observer. Is approval-request system-post leaking signal that external counsel shouldn't see? | Recommend: respect existing `chat_access` flag — observer reads, external counsel reads only if their `chat_access=true`. The same predicate as any chat post; the system just authors instead of a user. |
|
||||
| Q26 | **Edit window length**: 5 min as recommended, or shorter (1 min) / longer (15 min) / no edit at all? | Recommend 5 min. 1 min is too short for "wait did I tag the right person", 15 min is long enough for someone else to have read+replied based on the original. |
|
||||
| Q27 | **Markdown subset**: include or exclude blockquote? Tables? Strikethrough? | Recommend: blockquote ✅ (quoting prior message is the flat-thread alternative to sub-threading). Tables ❌ (unusual in chat, complicates renderer). Strikethrough ❌ (chat ≠ doc; rare and ambiguous). |
|
||||
| Q28 | **`@everyone` / `@team`**: support a "ping the whole project team" mention? | Recommend: NO in v1. Spam risk. Lead can post a normal message; team members on the thread already see it. Phase 2: optional `@team` for project leads only, with confirmation prompt. |
|
||||
| Q29 | **Chat unread badge: count messages or count threads?** | Recommend: count *messages* with caps at 99+. Threads-with-unread is easier on the eye but obscures volume; messages match user mental model of "you have N things to read". |
|
||||
| Q30 | **Sound on incoming message** (foreground tab)? | Recommend: NO by default. Opt-in setting (`users.chat_sound_enabled`) deferred to Phase 2. Lawyers in court rooms with their phone open is a real failure mode. |
|
||||
| Q31 | **Default landing on `/chat`**: most-recently-used thread, "Alle" thread list, or empty placeholder? | Recommend: most-recently-used thread (mirrors `/views` MRU pattern from t-144). First-time users land on an empty-state "Wähle einen Thread links". |
|
||||
| Q32 | **Chat post triggers `last_activity` bump** on associated project (drives sidebar sort, dashboard "recent activity") — yes/no? | Recommend: yes for chat threads themselves (sort thread list). NO for `paliad.projects.last_modified` (chat shouldn't ride sibling sort signals — that's reserved for case-substantive changes). |
|
||||
|
||||
m's go on these locks the design. If any answer flips, I rev the doc before handing to the implementer.
|
||||
|
||||
---
|
||||
|
||||
## §7 Trade-offs and risks
|
||||
|
||||
### 7.1 Adoption risk (the elephant in the room)
|
||||
|
||||
**The biggest risk is not technical — it's whether teams actually use this.** HLC colleagues already have:
|
||||
|
||||
- WhatsApp + Telegram for fast informal coordination.
|
||||
- Microsoft Teams / Outlook chat for firm-internal IM (assumption — verify).
|
||||
- Email for formal asynchronous comms.
|
||||
|
||||
paliad chat would need to attract `"Anna, kannst du auf meine Frist 16.05. drauf schauen?"` away from those tools. The differentiator m cited in the issue is compliance + context-rich (auto-resolve `#frist-1234`, team set is pre-derived). That's plausible — but the cost of building it is real, and if PA colleagues stick to WhatsApp, paliad chat becomes a half-empty room that signals "this product doesn't know its users".
|
||||
|
||||
**Recommendation before implementation:** m asks two PA colleagues from different offices ("would you actually use this if it existed?", "what would make you switch from WhatsApp?"). Either keeps you honest or surfaces feature gaps the design doesn't cover.
|
||||
|
||||
If adoption looks weak, alternative scopes worth considering:
|
||||
- **A: ship project chat only (no DM).** Project context is the real differentiator; DM is what WhatsApp does well. Less surface, less work, less risk of half-empty.
|
||||
- **B: ship `@mention + reply` as a comment thread on each deadline/termin first** — closer to the Verlauf pattern, lower lift, and validates the idea before the full chat surface.
|
||||
|
||||
### 7.2 Single-replica SSE constraint
|
||||
|
||||
Today's docker-compose is one `web` container. SSE works fine. If we ever scale (multi-Dokploy-replica, blue-green deploy with overlap), in-process bus drops cross-replica messages.
|
||||
|
||||
**Mitigation:** abstract `ChatBus` interface from day 1. Future `pgnotify.ChatBus` implementation is ~80 LoC and a one-line wiring change. Document this in `internal/services/chat_bus.go`.
|
||||
|
||||
### 7.3 Observer-write semantics
|
||||
|
||||
`observer` role is read-only for chat per recommendation. There's a UX edge case: an observer who *thinks* they're a regular member (because everyone else is chatting) and gets a write-disabled composer. Mitigate with a clear empty composer hint: "Du bist Beobachter:in für dieses Projekt — Lesezugriff nur." Same pattern as observer's read-only Frist edit.
|
||||
|
||||
### 7.4 External counsel default OFF chat
|
||||
|
||||
Defaulting `chat_access=false` for `local_counsel`/`expert` is the right compliance default but creates onboarding friction: the first time external counsel is added to a project, the lead has to explicitly toggle them in. **Mitigate** with a one-time hint in the team-add modal: "Externe Anwält:in/Sachverständige:r — Chat ist standardmäßig deaktiviert. Aktivieren?".
|
||||
|
||||
### 7.5 Markdown sanitisation correctness
|
||||
|
||||
Hand-rolling a small Markdown subset risks XSS through subtle edge cases (`[click](javascript:…)`, malformed image URI, etc.).
|
||||
|
||||
**Mitigate:**
|
||||
- Escape all rendered text first, then apply whitelisted Markdown tokens.
|
||||
- For URLs: validate `https?://` prefix with stdlib `url.Parse`; reject everything else.
|
||||
- Add a render test suite with known-bad payloads (data URIs, javascript: URIs, broken closures).
|
||||
- If we end up importing goldmark anyway, lean on its strict mode + a custom rendering walker.
|
||||
|
||||
### 7.6 Chat as a Verlauf-substitute
|
||||
|
||||
Risk that users start treating chat as the audit log ("I told Anna in chat to extend that deadline"). Verlauf is the audit; chat is conversation. Mitigate by:
|
||||
- The Phase 2 "Pin to Verlauf" affordance promotes specific chat messages to notes.
|
||||
- UX copy on the chat composer: "Notizen am Vorgang? → Verlauf." (small hint, not a wall).
|
||||
|
||||
### 7.7 Mobile keyboard + composer + bottom-nav
|
||||
|
||||
Mobile keyboards on iOS Safari overlap fixed bottom-nav elements. The composer needs to play nicely with that — anchor at viewport-bottom but adjust on focus. Standard pattern (the existing checklist comment composer probably has the same issue solved). Worth a quick check in implementation, not a design blocker.
|
||||
|
||||
### 7.8 chat_messages explosion
|
||||
|
||||
Multiplied across all paliad projects, chat could grow to millions of rows over years. Indexes on `(thread_id, created_at DESC)` keep reads fast. PG handles 10M+ rows with ease at this index shape. Storage cost is negligible. Document the size projection in the impl plan but don't pre-optimize.
|
||||
|
||||
---
|
||||
|
||||
## §8 Phasing
|
||||
|
||||
**Phase 1 (chat MVP — bundled v1, single PR):** ~3500–4500 LoC
|
||||
|
||||
1. Migration 057 (chat schema + `project_teams.chat_access`).
|
||||
2. `ChatService` + `ChatBus` interface + in-process implementation.
|
||||
3. HTTP endpoints (8 in §10).
|
||||
4. SSE stream endpoint with heartbeat + Last-Event-ID resume.
|
||||
5. `frontend/src/chat.tsx` + client `client/chat.ts` + Markdown renderer.
|
||||
6. `frontend/src/components/Sidebar.tsx` updated with Chat entry + badge.
|
||||
7. Per-project Chat tab on `/projects/{id}`.
|
||||
8. Approval auto-post wiring in `ApprovalService.Submit*`.
|
||||
9. ~80 i18n keys DE+EN.
|
||||
10. CSS for chat shell + bubbles + mention chips + composer.
|
||||
|
||||
**Phase 2** (~2000 LoC each, separate PRs as demand justifies):
|
||||
- Email digest of unread chats (composes with reminder pipeline).
|
||||
- PWA push notifications (VAPID + SW push handler + subscription endpoint).
|
||||
- File attachments (chat → `paliad.documents`).
|
||||
- Cross-thread search (FTS index + global search).
|
||||
- "Pin to Verlauf" affordance.
|
||||
|
||||
**Phase 3+** (defer until Phase 1+2 usage validates):
|
||||
- Per-deadline / per-termin micro-threads.
|
||||
- Partner-unit rooms.
|
||||
- Reactions, sub-threads, `@team`, sound/Notification config UI.
|
||||
- Topical/cross-cutting rooms.
|
||||
|
||||
**Optional Phase 1 split (if implementer prefers):**
|
||||
- 1A — Schema + `ChatService` + REST endpoints + project chat shell. No DMs, no SSE (polling stub for unread badge).
|
||||
- 1B — DMs + SSE + mentions + entity-refs + approval auto-post.
|
||||
|
||||
If the implementer splits, they own the call. Both 1A+1B in a single PR is ~4500 LoC; each in its own PR is ~2000-2500. m can decide on the split when locking the design.
|
||||
|
||||
---
|
||||
|
||||
## §9 Implementer recommendation
|
||||
|
||||
**Recommended worker: noether (this worktree)** or a fresh coder.
|
||||
|
||||
Pattern-fluent Sonnet work; nothing here requires Opus-level architectural reasoning past this design. The substrate (visibility predicate, project_teams shape, SSE handling, sidebar/badge pattern, ViewService precedent) is well-trodden — implementation is mostly composition.
|
||||
|
||||
NOT cronus per memory directive (cronus retired from paliad).
|
||||
|
||||
Expected files (Phase 1):
|
||||
|
||||
- `internal/db/migrations/057_chat.{up,down}.sql`
|
||||
- `internal/services/chat_service.go`
|
||||
- `internal/services/chat_bus.go`
|
||||
- `internal/services/markdown.go` (small renderer)
|
||||
- `internal/handlers/chat.go`
|
||||
- `internal/handlers/chat_stream.go` (SSE)
|
||||
- `internal/handlers/handlers.go` (route wiring under `if svc.Chat != nil`)
|
||||
- `internal/services/approval_service.go` (auto-post hook on Submit*)
|
||||
- `cmd/server/main.go` (`chatBus := services.NewInProcessChatBus(); chatSvc := services.NewChatService(pool, …, chatBus)`)
|
||||
- `frontend/src/chat.tsx` (page shell)
|
||||
- `frontend/src/projects-detail.tsx` (Chat tab integration)
|
||||
- `frontend/src/client/chat.ts` (orchestration, EventSource, autocomplete, edit/delete, read marker)
|
||||
- `frontend/src/client/markdown.ts` (render-side companion if any)
|
||||
- `frontend/src/client/sidebar.ts` (badge + unread-count fetch)
|
||||
- `frontend/src/components/Sidebar.tsx` (new Chat entry)
|
||||
- `frontend/src/styles/global.css` (chat-shell + chat-bubble + chat-mention + chat-composer styles)
|
||||
- `frontend/src/i18n.ts` (~80 keys DE+EN)
|
||||
- `frontend/src/build.ts` (chat.html bundle)
|
||||
|
||||
---
|
||||
|
||||
## §10 HTTP endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/chat` | Chat shell page |
|
||||
| GET | `/chat/dm/<thread_id>` | Deep-link to specific DM thread (server resolves visibility, redirects to /chat with state) |
|
||||
| GET | `/api/chat/threads` | List threads visible to caller (project + DM), sorted by last_activity. Includes per-thread unread count. |
|
||||
| POST | `/api/chat/dm` | Body `{ "participant_ids": [...], "title": "..." }`. Returns thread (idempotent for 1:1 by sorted participant set). |
|
||||
| GET | `/api/chat/threads/<id>/messages?before=<msg_id>&limit=50` | List messages with cursor pagination. Returns rendered HTML + raw source per message. |
|
||||
| POST | `/api/chat/threads/<id>/messages` | Post message. Body `{ "body": "..." }`. Server parses mentions/refs, inserts, publishes bus event. |
|
||||
| PATCH | `/api/chat/messages/<id>` | Edit (5min author window). Body `{ "body": "..." }`. |
|
||||
| DELETE | `/api/chat/messages/<id>` | Soft-delete (author or admin). |
|
||||
| POST | `/api/chat/threads/<id>/read` | Body `{ "up_to_message_id": "..." }`. Updates `chat_reads`. |
|
||||
| GET | `/api/chat/unread-count` | Sidebar badge. Returns `{ "total": N, "by_thread": {...} }`. |
|
||||
| GET | `/api/chat/autocomplete?q=&context=<thread_id>` | Server-resolved mention/entity-ref autocomplete. |
|
||||
| GET | `/api/chat/stream` | SSE long-lived; returns events filtered to caller's visibility. |
|
||||
|
||||
---
|
||||
|
||||
## §11 Frontend shape (Phase 1)
|
||||
|
||||
```
|
||||
/chat [/chat]
|
||||
┌───────────────────┬──────────────────────────────────────┐
|
||||
│ THREADS │ Siemens AG · Litigation UPC München │
|
||||
├───────────────────┼──────────────────────────────────────┤
|
||||
│ Alle | Proj | DM │ ┌──────────────────────────────────┐ │
|
||||
│ │ │ Anna · 14:23 │ │
|
||||
│ ▶ Siemens AG · L. │ │ Hat jemand auf Frist #frist-1234 │ │
|
||||
│ 3 ungelesen │ │ drauf geschaut? Replik bis Mo. │ │
|
||||
│ ▷ EP1234567 │ └──────────────────────────────────┘ │
|
||||
│ ▷ DM mit Anna │ ┌──────────────────────────────────┐ │
|
||||
│ ▷ DM (3) UPC-Team │ │ ── neue Nachrichten ────────────│ │
|
||||
│ │ ├──────────────────────────────────┤ │
|
||||
│ │ │ Lukas · 14:30 │ │
|
||||
│ │ │ Schau gleich rein, ist das EAU? │ │
|
||||
│ │ └──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ├──────────────────────────────────────┤
|
||||
│ │ ┌──────────────────────────────────┐ │
|
||||
│ │ │ @anna danke! … ▶│ │
|
||||
│ │ └──────────────────────────────────┘ │
|
||||
└───────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
On mobile: thread list is a full page, tap → message page.
|
||||
|
||||
On `/projects/{id}?tab=chat`: messages pane only (thread list hidden), with project header above.
|
||||
|
||||
System-post visual:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ 🔔 Anna hat Genehmigung angefordert: │
|
||||
│ Frist 16.05. (Replik einreichen) │
|
||||
│ [ Zur Genehmigung → ] │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
(Distinct background, no edit/delete affordance, deep-link button.)
|
||||
|
||||
---
|
||||
|
||||
## §12 Concrete recommendation summary
|
||||
|
||||
| # | Question | Recommendation |
|
||||
|---|---|---|
|
||||
| Q1 | Surface set v1 | Per-project + DMs |
|
||||
| Q2 | Hierarchy visibility | Per-thread, predicate = `can_see_project` |
|
||||
| Q3 | Approval cross-cut | System auto-post, no replacement |
|
||||
| Q4 | Real-time arch | SSE |
|
||||
| Q5 | Notification path | In-app badge + tab-flash + Notification API; defer push + email digest |
|
||||
| Q6 | Read/unread | Per-(user,thread) last-read marker; no per-message receipts |
|
||||
| Q7 | Body format | Markdown subset (no headings, no images) |
|
||||
| Q8 | Mentions + refs | `@user`, `#frist-…`, `#projekt-…`, `#termin-…`, `#approval-…` |
|
||||
| Q9 | Attachments | Defer Phase 2 |
|
||||
| Q10 | Edits / deletes | Edit ≤5 min author; soft-delete author or admin |
|
||||
| Q11 | Threading | Flat |
|
||||
| Q12 | Search | Thread-scoped LIKE; defer cross-thread |
|
||||
| Q13 | Schema | Migration 057: 5 new tables + `project_teams.chat_access` |
|
||||
| Q14 | Retention | Forever, soft-delete only |
|
||||
| Q15 | Verlauf | No; "Pin to Verlauf" Phase 2 |
|
||||
| Q16 | Sidebar entry | Both — top-level Chat + per-project tab |
|
||||
| Q17 | Custom Views | NOT a 5th source |
|
||||
| Q18 | Bulk email overlap | Distinct surfaces |
|
||||
| Q19 | Who can chat | Read = visibility; observer read-only; external opt-in via `chat_access` |
|
||||
| Q20 | External counsel/expert | Default `chat_access=false`; lead toggles per project |
|
||||
| Q21 | PWA push | Defer Phase 2 |
|
||||
| Q22 | DM reachability | Scoped to "shares ≥1 visible project" |
|
||||
| Q23 | DM small-group cap | 8 |
|
||||
| Q24 | Auto-provision | Lazy on first read |
|
||||
| Q25 | System-post audience | Same as any chat post (respects `chat_access`) |
|
||||
| Q26 | Edit window | 5 min |
|
||||
| Q27 | Markdown subset | Blockquote yes; tables no; strikethrough no |
|
||||
| Q28 | `@everyone` / `@team` | No in v1 |
|
||||
| Q29 | Badge count | Messages, capped at 99+ |
|
||||
| Q30 | Sound | No (opt-in deferred) |
|
||||
| Q31 | Default landing | Most-recently-used thread |
|
||||
| Q32 | last_activity bump | Yes on chat thread; no on project record |
|
||||
|
||||
---
|
||||
|
||||
## §13 Open follow-ups (not for v1)
|
||||
|
||||
- **Bot integrations** (e.g. /Frist-Bot for natural-language deadline lookup). Out of scope; if AI chat (`feature-roadmap.md`) ever ships, it lives at `/ask` not `/chat`. Reserve mental separation.
|
||||
- **External-firm participants** (opposing counsel, expert witnesses outside HLC). Big compliance question; not v1 / not v2 / not yet.
|
||||
- **Slack / Teams bridging**. Very tempting and very complex (auth, identity mapping, message format translation). Defer until paliad chat usage justifies.
|
||||
- **Voice messages** (German lawyers love voice notes). Out of scope.
|
||||
|
||||
---
|
||||
|
||||
## §14 What I need from m to lock
|
||||
|
||||
1. **§7.1 adoption sanity-check**: are PAs likely to use this, or is it a half-empty surface?
|
||||
2. **Q1 — surface set**: confirm per-project + DMs, defer per-deadline / per-termin / partner-unit / topical.
|
||||
3. **Q4 — SSE**: confirm SSE direction.
|
||||
4. **Q5 — notification path**: confirm in-app-only v1 (push + email digest deferred).
|
||||
5. **Q19/Q20 — chat_access flag** on `project_teams`, defaulting OFF for `local_counsel`/`expert`.
|
||||
6. **Q22–Q32 — the 11 follow-up questions** in §6.
|
||||
7. **§8 phasing** — single PR or 1A+1B split.
|
||||
|
||||
If m greenlights with "I agree with all your recommendations - go." (the Q4 of t-139 pattern), I lock the design and the head routes the coder shift.
|
||||
|
||||
If m flips any answer, I rev the doc before handover.
|
||||
|
||||
**Inventor parks here.** No coder self-load.
|
||||
|
||||
---
|
||||
|
||||
## §15 Appendix — file/index inventory
|
||||
|
||||
For the implementer's reference; verified live 2026-05-07.
|
||||
|
||||
**Existing tables touched:**
|
||||
- `paliad.project_teams` — new column `chat_access`, backfill external roles.
|
||||
- `paliad.projects` — read-only, source for `path` traversal.
|
||||
- `paliad.users` — read-only, FK target.
|
||||
- `paliad.partner_unit_members`, `paliad.project_partner_units` — read-only, derivation predicate.
|
||||
|
||||
**New tables:**
|
||||
- `paliad.chat_threads`
|
||||
- `paliad.chat_thread_participants`
|
||||
- `paliad.chat_messages`
|
||||
- `paliad.chat_reads`
|
||||
- `paliad.chat_mentions`
|
||||
|
||||
**Existing Go services touched:**
|
||||
- `internal/services/visibility.go` — read-only reuse.
|
||||
- `internal/services/derivation_service.go` — read-only reuse for partner-unit derivation check.
|
||||
- `internal/services/approval_service.go` — auto-post hook on `Submit*`.
|
||||
|
||||
**New Go services:**
|
||||
- `internal/services/chat_service.go`
|
||||
- `internal/services/chat_bus.go` (interface + in-process default)
|
||||
- `internal/services/markdown.go`
|
||||
|
||||
**Existing handlers touched:**
|
||||
- `internal/handlers/handlers.go` — wire chat routes when `Chat != nil`.
|
||||
|
||||
**New handlers:**
|
||||
- `internal/handlers/chat.go`
|
||||
- `internal/handlers/chat_stream.go`
|
||||
|
||||
**Existing frontend touched:**
|
||||
- `frontend/src/components/Sidebar.tsx`
|
||||
- `frontend/src/projects-detail.tsx` (Chat tab)
|
||||
- `frontend/src/client/sidebar.ts` (badge update)
|
||||
- `frontend/src/i18n.ts` (~80 new keys)
|
||||
- `frontend/src/build.ts` (chat bundle)
|
||||
- `frontend/src/styles/global.css`
|
||||
|
||||
**New frontend:**
|
||||
- `frontend/src/chat.tsx`
|
||||
- `frontend/src/client/chat.ts`
|
||||
- `frontend/src/client/markdown.ts` (or shared with views)
|
||||
|
||||
— end of design —
|
||||
955
docs/design-paliadin-2026-05-07.md
Normal file
955
docs/design-paliadin-2026-05-07.md
Normal file
@@ -0,0 +1,955 @@
|
||||
# Design: Paliadin — in-app AI buddy / pet (t-paliad-146)
|
||||
|
||||
**Status:** READY FOR REVIEW (revised 2026-05-07 20:56 — PoC track inserted)
|
||||
**Author:** noether (inventor)
|
||||
**Issue:** [m/paliad#9](https://mgit.msbls.de/m/paliad/issues/9)
|
||||
**Date:** 2026-05-07
|
||||
**Branch:** `mai/noether/inventor-paliadin-in-app`
|
||||
|
||||
> **Revision note (2026-05-07 20:56):** m re-scoped this from "ship to HLC users" → **"PoC for m, monitor usage, expand only if it earns it"**. The original Anthropic-API design in §2–§6 is preserved as the production-v1 spec, but **§0.5 (new) supersedes it for what gets built first**: a tmux-Claude PoC lifted from goldi/mVoice, m-only on his laptop, with monitoring instrumentation as the load-bearing instrument for the expand/kill decision. §7 (Phasing) and §8.5 (Open questions) are revised to reflect the two-stage shape.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
A new conversational surface inside paliad: **Paliadin**, a Claude‑backed assistant that answers questions grounded in the user's own paliad data and paliad's domain knowledge. The Paliadin is a long‑lived in‑process Go service, not a per‑session worker spawn — it talks to the Anthropic Messages API directly with **tool use**, where every tool is a thin shim over an existing paliad service (DashboardService, ProjectService, DeadlineService, CourtService, GlossaryService, DeadlineRuleService, AgendaService). RLS / visibility is enforced at the service layer, exactly as it is for the rest of the app, so Paliadin literally cannot see what the caller cannot see.
|
||||
|
||||
Phase 1 surface: **dedicated `/paliadin` page + a sidebar entry under "Übersicht"**, server‑side SSE stream of Anthropic's response (same shape paliad's parked t‑145 chat design specced), session‑only conversation (no DB persistence in v1), 7 read‑only tools, ~30 turns/hour rate limit per user, hard token caps (4 k input + 2 k output per turn), per‑request audit row (no full transcript v1 — store a redacted hash + token counts + tool‑call list).
|
||||
|
||||
**No avatar, no mascot SVG, no proactive onboarding pop‑up in v1.** Just a clean chat panel with the name "Paliadin" in the header. Mascot, drawer mode, persistent threads, write‑tools, and youpc.org case‑law lookup all deferred to Phase 2/3.
|
||||
|
||||
**mlex / `/lex-*` reuse: pattern, not code.** mLex turns out to be a *workspace* (`extractions/`, `analysis/`, `docs/`) — there is no Go/TS code to fork. The `/lex-*` skills are Claude Code instruction docs that drive *Claude itself* against youpc's MCP tools; they cannot be embedded in a paliad Go service. What carries over is the **shape**: tool catalog (search → fetch → cite), system‑prompt voice (precise, citation‑backed, flag uncertainty honestly), and the "every legal claim needs a citation" guardrail. §2.4 maps the carry‑over precisely.
|
||||
|
||||
**Trade‑off flagged up‑front (read §9.1 before approving):** the same adoption‑risk concern that just parked the local‑chat design (t‑paliad‑145, today 17:03) applies here. Paliadin's edge over "open ChatGPT in another tab" is *only* that it sees the user's own data — and that edge collapses if v1 doesn't make the data‑grounding visible (citation chips, tool‑call evidence) and explicit ("Paliadin sees only YOUR projects"). Without those, Paliadin is just a worse Claude. With them, it's the only Claude that can answer "welche Frist ist als nächstes auf dem Müller‑Verfahren?".
|
||||
|
||||
---
|
||||
|
||||
## §0.5 PoC track — m-only, monitored, expandable (REVISED 2026-05-07 20:56)
|
||||
|
||||
**This section supersedes §2–§7 for what actually gets built first.** §2–§6 stay valid as the production‑v1 spec; they're picked up only if the PoC earns expansion.
|
||||
|
||||
### 0.5.1 Why the re-scope
|
||||
|
||||
m's reframing: "Paliadin is mostly for myself now but can be expanded — monitoring use." Two consequences:
|
||||
|
||||
1. **Single user (m) on m's laptop**, not 38 HLC PAs on paliad.de. Multi‑tenant concerns drop. RLS still matters because m's `global_role=global_admin` shouldn't let Paliadin sweep data across projects sloppily, but the cross‑user PII surface goes to zero.
|
||||
2. **The build is for m to feel the UX and decide whether to expand.** That makes monitoring instrumentation load‑bearing — it's the artefact that drives the next decision, not a compliance afterthought. PoC architecture: cheap to ship, expensive to *not* observe.
|
||||
|
||||
### 0.5.2 Architecture: lift goldi/mVoice tmux‑Claude
|
||||
|
||||
Verified pattern in `~/dev/mVoice/server.py:250–380` (and `~/dev/goldi/goldi/brain.py` for the soul/prompt assembly). Working production code today on m's voice stack.
|
||||
|
||||
```
|
||||
┌──────────────────────┐ POST /api/paliadin/turn ┌────────────────────────────┐
|
||||
│ Browser │ ────────────────────────────────▶ │ paliad Go server (laptop) │
|
||||
│ /paliadin chat panel │ │ │
|
||||
│ │ ◀──────── SSE stream ──────────── │ PaliadinService │
|
||||
└──────────────────────┘ (file‑tail of response) │ ├─ ensure tmux session │
|
||||
│ ├─ tmux send-keys -l … │
|
||||
│ ├─ poll/tail │
|
||||
│ │ /tmp/paliadin/{tid} │
|
||||
│ └─ audit row write │
|
||||
└──────────────┬─────────────┘
|
||||
│ tmux send-keys
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ tmux: paliad-paliadin │
|
||||
│ window: claude-paliad │
|
||||
│ $ claude (interactive) │
|
||||
│ w/ system prompt + │
|
||||
│ mcp__supabase__* │
|
||||
│ scoped to paliad.* │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
Lift verbatim from mVoice:
|
||||
|
||||
- `_ensure_voice_session()` → `_ensure_paliadin_session()`. Same `tmux has-session` / `new-session` / `new-window` / "wait for ❯ prompt" dance.
|
||||
- `tmux_generate(prompt) → response` → same shape, just reads via tail‑f instead of one‑shot poll so we can stream deltas to the SSE consumer (see §0.5.5).
|
||||
- `_reset_paliadin_session()` for `/clear` — surfaced in the chat panel's "New conversation" button.
|
||||
|
||||
### 0.5.3 What we keep from §2–§6 (it's still right)
|
||||
|
||||
| Section | Carry‑over | Why it survives the re‑scope |
|
||||
|---|---|---|
|
||||
| §2.2.1 system prompt template | ✅ ported as the *first message* sent into Claude after `/clear` | The voice + guardrails (no fabrication, cite specifically, can't mutate) are exactly what we want. Just delivered via tmux send-keys instead of API `system:` field. |
|
||||
| §2.5 tool catalog | ✅ but *as instructions, not as wrappers* | Claude already has `mcp__supabase__execute_sql`. The system prompt teaches it the read patterns ("to find m's pending deadlines: `SELECT … FROM paliad.deadlines WHERE status='pending' AND paliad.can_see_project(project_id)`"). Zero Go shim code; ~15 SQL recipes in the prompt. |
|
||||
| §3.2 visibility gate | ✅ | The system prompt *requires* `paliad.can_see_project(project_id)` in every project‑scoped query. Defence in depth: the supabase MCP runs with a service role, so RLS doesn't auto‑gate — the prompt rule is the gate, and we cross‑check via audit (§0.5.6). |
|
||||
| §4 surface placement (`/paliadin` full page + sidebar entry) | ✅ | Same UI shell. |
|
||||
| §4.5 streaming + interruption | ✅ adapted | SSE stream still happens; backing source is `tail -f /tmp/paliadin/{turn_id}.txt` instead of Anthropic's stream events. Choppier but works. |
|
||||
| §4.4 action chips | ⚠ best‑effort | System prompt asks Claude to emit `[#deadline-OPEN:c47bd2]` markers; whether it does so reliably is an *observation* the PoC will surface. |
|
||||
| §5.4 audit table (`paliad.paliadin_turns`) | ✅ | Reused for monitoring (§0.5.6). Added: `pane_lines_captured` so we can debug stream issues. Dropped: `input_tokens`/`output_tokens` (Claude Code doesn't expose these via the tmux interface — derive coarse cost estimate from elapsed time × Claude Code's published rates if we want it later). |
|
||||
|
||||
### 0.5.4 What we drop for the PoC
|
||||
|
||||
| Drop | Reason |
|
||||
|---|---|
|
||||
| Anthropic Messages API client (`anthropic.go`) | Replaced by tmux/Claude. Saves ~400 LoC. |
|
||||
| Per‑user rate limit (`paliadin_rate_limit` table) | Single user. m's own restraint is the rate limit. Re-add at expansion. |
|
||||
| Token caps + history truncation | Claude Code manages its own context window. |
|
||||
| BYO‑AI / OpenAI adapter | Out of scope — m's prior message; punted. |
|
||||
| Multi‑user RLS edge cases (cross‑user PII) | Single‑user; not exercised. |
|
||||
| Compliance disclosure on first use | m → m's own Claude subscription. m has already accepted Anthropic's TOS. |
|
||||
| `/admin/paliadin` cost dashboard | One user; cost is m's monthly Claude bill. |
|
||||
| Most i18n keys | m switches DE/EN naturally; ~6 keys instead of ~25. |
|
||||
|
||||
### 0.5.5 SSE shape adapted to tmux backing
|
||||
|
||||
Same event vocabulary as §4.5.1, fed by a goroutine that tails `/tmp/paliadin/{turn_id}.txt` and emits content_delta events as new bytes arrive. Trade‑offs:
|
||||
|
||||
- **Latency to first token:** ~3–8 s (Claude Code "thinking" before first write). Worse than native API streaming. Mitigation: surface a "Paliadin denkt nach …" placeholder bubble until the first byte arrives.
|
||||
- **No native tool‑call events.** Claude Code does its tool‑use internally; we see only the final text written to the response file. To still surface "ran search_my_deadlines (3 results)" evidence, the system prompt instructs Claude to write a structured trailer block at the end of its response: `\n\n---\n[paliadin-meta]\nused_tools: search_my_deadlines, lookup_court\nrows_seen: 3, 1\n[/paliadin-meta]\n`. Frontend strips that block and renders it as the citation evidence row. Brittle but observable; this is the kind of thing the PoC's monitoring is for.
|
||||
- **Heartbeat:** still emit `event: ping` every 25 s so the SSE connection survives any reverse proxy. (Not strictly needed on `localhost` but keeps the production migration cheap.)
|
||||
|
||||
### 0.5.6 Monitoring instrumentation — the load‑bearing artefact
|
||||
|
||||
Because the whole point of the PoC is "watch m use it", the audit shape is the most important thing in the PoC ship.
|
||||
|
||||
**Migration 057 (PoC variant):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.paliadin_turns (
|
||||
turn_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
duration_ms int, -- end - start
|
||||
user_message text, -- FULL prompt (m‑only PoC; redact at expansion)
|
||||
response text, -- FULL response (same)
|
||||
response_tokens int, -- approx via word count × 1.3
|
||||
used_tools text[], -- parsed from [paliadin-meta] trailer
|
||||
rows_seen int[], -- parallel to used_tools
|
||||
chip_count int NOT NULL DEFAULT 0,
|
||||
abandoned boolean NOT NULL DEFAULT false, -- user closed mid-stream
|
||||
page_origin text, -- which paliad page m was on when he asked
|
||||
error_code text, -- 'tmux_unresponsive', 'pane_died', 'user_aborted', NULL on ok
|
||||
classifier_tag text -- coarse self-classification: 'data', 'concept', 'navigation', 'meta', 'other'
|
||||
);
|
||||
|
||||
CREATE INDEX paliadin_turns_started_idx
|
||||
ON paliad.paliadin_turns(started_at DESC);
|
||||
```
|
||||
|
||||
Critical departure from the production design: at PoC scope **we DO store the full prompt + response**. m is the only user, m is m's own compliance officer, and the whole point is to *read what was asked* later. Redaction returns at expansion.
|
||||
|
||||
**`/admin/paliadin` page (PoC variant)** renders:
|
||||
|
||||
- 7‑day rolling turn count + median/p90 duration.
|
||||
- Histogram by `classifier_tag` (so m sees: "60 % of my queries were 'data', 25 % 'concept', 10 % 'navigation', 5 % 'meta'" — that's the use‑case shape).
|
||||
- Top 10 prompts by frequency (textually similar grouping via simple normalised string match — fancy clustering is Phase 1 expansion).
|
||||
- Tool‑use rate (turns where `used_tools` is non-empty / total turns). **Load‑bearing for the expansion decision** — see §0.5.7.
|
||||
- Abandonment rate (`abandoned=true / total`).
|
||||
- Daily usage sparkline.
|
||||
|
||||
The classifier_tag is set by Claude itself in the `[paliadin-meta]` trailer, instructed by the system prompt — same brittleness caveat as the tool‑use evidence.
|
||||
|
||||
### 0.5.7 The expansion gate — what triggers production v1?
|
||||
|
||||
**m decides; this section gives m the metric set he asked for.** Suggested green‑light criteria after 4 weeks:
|
||||
|
||||
1. **Sustained use:** ≥ 3 turns/working‑day average over weeks 3–4.
|
||||
2. **Data‑grounded use:** tool‑use rate ≥ 50 % (otherwise Paliadin is being used like ChatGPT and there's no differentiation argument for the production build).
|
||||
3. **Useful by m's own gut.** No metric beats this; the dashboard helps m frame it but doesn't decide for him.
|
||||
|
||||
**Yellow flag criteria** (interesting but not green):
|
||||
|
||||
- < 1 turn/day → m isn't using it; either kill or rebuild the affordance to be more discoverable.
|
||||
- Tool‑use rate < 30 % → the value isn't in the data grounding; reconsider the whole premise.
|
||||
- High abandonment rate → UX issue (latency? wrong answers? broken streaming?). Investigate before expansion.
|
||||
|
||||
**Kill criteria:**
|
||||
|
||||
- m looks at the dashboard 4 weeks in and shrugs.
|
||||
- Frequent tmux session deaths or `/clear`-too-often patterns suggest the architecture is fighting m. PoC failure ≠ Paliadin failure; might be the tmux pattern's failure.
|
||||
|
||||
### 0.5.8 PoC scope — what gets built
|
||||
|
||||
| Item | In PoC |
|
||||
|---|---|
|
||||
| `internal/services/paliadin/tmux.go` (lifted + adapted from `mVoice/server.py:250–380`) | ✅ |
|
||||
| `internal/services/paliadin/prompt.go` (system prompt template + `[paliadin-meta]` trailer rule) | ✅ |
|
||||
| `internal/services/paliadin/sse.go` (file‑tail → SSE relay) | ✅ |
|
||||
| `internal/handlers/paliadin.go` (POST /turn, GET /stream/{id}, /paliadin shell page, /admin/paliadin dashboard) | ✅ |
|
||||
| Migration 057 — PoC `paliadin_turns` (full prompt + response stored) | ✅ |
|
||||
| `frontend/src/paliadin.tsx` + `client/paliadin.ts` (chat panel, EventSource, chip parser, "Stop"/"New" buttons) | ✅ |
|
||||
| `frontend/src/admin-paliadin.tsx` + `.ts` (the monitoring dashboard) | ✅ |
|
||||
| Sidebar entry under Übersicht with `ICON_SPARKLE` | ✅ |
|
||||
| ~6 i18n keys (DE+EN) | ✅ |
|
||||
| `PALIADIN_TMUX_SESSION` env var (default `paliad-paliadin`), `PALIADIN_RESPONSE_DIR` (default `/tmp/paliadin`), `PALIADIN_ENABLED` (default false on prod, true on m's laptop) | ✅ |
|
||||
| **Hard guard:** if `PALIADIN_ENABLED=false` (paliad.de prod default) the routes are not even registered. PoC stays on m's laptop, full stop. | ✅ |
|
||||
|
||||
**Estimated scope:** ~600–900 LoC. ~1 day of coder work. Same single‑PR pattern as t‑144 / t-145.
|
||||
|
||||
### 0.5.9 What stays unbuilt (production v1, see §2–§6)
|
||||
|
||||
The Anthropic API client, the 7 Go tool shims, the per‑user rate limit, the encrypted‑key BYO‑AI surface, the redacted audit, the multi‑replica SSE bus — all of it. Picked up only if §0.5.7's expansion gate fires.
|
||||
|
||||
**The two‑stage shape protects against the t‑145 pattern:** ship cheap, observe, decide. No 4500‑LoC investment based on m's gut feel about adoption.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-07)
|
||||
|
||||
Before designing on top, I checked each load‑bearing claim against the running system rather than CLAUDE.md / memory.
|
||||
|
||||
| Claim | Source | Verification |
|
||||
|---|---|---|
|
||||
| **mLex is a workspace, not a code repo** | issue framing "mlex project we could partially reuse" | `~/dev/mLex/` contains only `extractions/`, `analysis/`, `docs/`, plus `CLAUDE.md` + `AGENTS.md`. No `*.go`, no `package.json`, no tools that aren't Claude skills. The "code" is the `/lex-*` skill family in `~/.claude/skills/`, which is instruction docs driving Claude against `mcp__youpc__*` MCP tools. **Carry‑over is shape (system prompt, tool catalog, citation style), not adapters.** |
|
||||
| `/lex-*` skill family | brief reference | `~/.claude/skills/{lex-research,lex-extract,lex-classify,lex-classify-patent,mai-lexy}/SKILL.md`. All five inventoried in §2.4. |
|
||||
| Paliad has no anthropic / claude code | CLAUDE.md `ANTHROPIC_API_KEY` "do not set" row | `grep -ri anthropic ~/dev/paliad/internal ~/dev/paliad/cmd` → only `internal/branding/firm.go` comment unrelated to AI. `go.mod` has no `anthropic-sdk-go` dep. **This task un‑defers the env var; CLAUDE.md row needs updating in the same PR.** |
|
||||
| Paliad has no SSE pattern shipped | substrate scan | `grep -rn 'http.Flusher\|text/event-stream' internal/` returns only references inside the parked t‑145 chat design doc — no live code. We bring our own. |
|
||||
| Paliad and youpc share the same physical Postgres | infra | Both run on `100.99.98.201:11833` (port 11833 = ydb). Paliad's schema is `paliad`; youpc's is `data`. **A future "search UPC case law" tool would be a same‑DB cross‑schema SELECT, not an HTTP hop** — but Phase 1 still excludes case‑law lookup (see §3). |
|
||||
| Visibility is enforced at service layer (not via SET LOCAL auth.uid) | code | `internal/services/visibility.go` defines `visibilityPredicate(alias)` + `visibilityPredicatePositional(alias, idx)`; every project‑scoped query inlines it. Paliadin's tools call existing services, inheriting the predicate. |
|
||||
| `paliad.can_see_project()` is the canonical visibility function in DB (RLS, t‑139) | t‑139 migration 055 | `internal/db/migrations/055_hierarchy_aggregation.up.sql:144` `CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)`. Same predicate echoed in `services/visibility.go`. |
|
||||
| Migration tracker is at 56 (`056_user_views`) | t‑144 A1 | `paliad_schema_migrations` row. Next migration is **057**. (t‑145 was parked before its `057_chat` shipped, so 057 is open.) |
|
||||
| t‑paliad‑145 (local chat) was parked today 2026-05-07 17:03 | memory + commit log | Commit `99f08e3` "Merge: t-paliad-145 design doc only — local chat feature PARKED per m's call". The chat SSE substrate that would have been shared is **not** built — Paliadin builds its own minimal stream. |
|
||||
| Sidebar bell pattern (`sidebar-inbox-badge`) is reusable for a chat‑style entry | t‑138 | `frontend/src/components/Sidebar.tsx` — `navItem(href, icon, i18nKey, label, currentPath, badgeID?)` already takes an optional badge id. The same plumbing fits a Paliadin entry. |
|
||||
| Sidebar `ICON_SPARKLE` already exists | UI scan | `frontend/src/components/Sidebar.tsx` defines `ICON_SPARKLE` (a star/sparkle SVG). Free icon for the Paliadin nav item. |
|
||||
| `auth.UserIDFromContext(r.Context())` is the standard handler‑side user lookup | code | `internal/handlers/dashboard.go:31` is the canonical pattern. Paliadin handlers will use it. |
|
||||
| `branding.Name` (default "HLC") is the firm‑name source | t‑paliad‑065 | `internal/branding/firm.go` reads `FIRM_NAME` once at boot. Paliadin's system prompt + greeting must use `branding.Name`, never hardcode "HLC". |
|
||||
| Single web replica on Dokploy today | `docker-compose.yml` | One `web` service. SSE state in‑process is fine v1; multi‑replica migration deferred along with chat. |
|
||||
|
||||
**Doc‑vs‑live conflicts encountered (must be fixed in the implementation PR):**
|
||||
|
||||
1. **CLAUDE.md** still says `ANTHROPIC_API_KEY` is "Reserved for Phase H (AI Frist‑Extraktion) which is deferred per m's 2026-04-16 decision. Do not set." Paliadin un‑defers it. The CLAUDE.md row needs to flip to "Required for Paliadin (read‑only Claude assistant) — set on Dokploy."
|
||||
2. The earlier "do not want anthropic API" decision (memory `b6a11b55…`, 2026-04-16) was specifically about *Frist extraction from documents*. Paliadin is a different surface (interactive read‑only Q&A over already‑structured data). It does not silently revive the parked extraction feature — t‑paliad‑011 stays blocked unless m explicitly un‑parks it too.
|
||||
|
||||
---
|
||||
|
||||
## §2 Sub-design A — LLM architecture, prompt, tool use, mlex/lex reuse
|
||||
|
||||
Answers Q1, Q2, Q3, Q4, Q17, Q18.
|
||||
|
||||
### 2.1 LLM provider (Q1)
|
||||
|
||||
**Recommendation: Anthropic Claude, single provider, accessed directly via the Messages API. Lock to Claude in v1; abstract behind a one‑function interface so future portability is cheap.**
|
||||
|
||||
| Provider | v1? | Why |
|
||||
|---|---|---|
|
||||
| Anthropic Claude (Messages API + tool use) | ✅ | Matches m's "wire into my claude" framing. Tool‑use shape is mature. Streaming via SSE is native. Paliad already has `ANTHROPIC_API_KEY` reserved. |
|
||||
| Mixed (Claude reasoning + smaller routing model) | ❌ | Premature optimisation; for ~30 turns/hour/user we don't need the routing layer. Single‑model latency is fine. |
|
||||
| OpenAI / open weight | ❌ | No HLC compliance review for those vendors; m's Anthropic key is on file. |
|
||||
|
||||
**Model selection within Anthropic:** default to **Claude Sonnet 4.6** (fast, tool‑use‑capable, cheap enough for chat use). Allow override via `PALIADIN_MODEL` env var so we can drop down to Haiku for cost or up to Opus for tricky onboarding sessions without redeploying.
|
||||
|
||||
**Wire shape:** one Go HTTP client (`internal/services/paliadin/anthropic.go`) that POSTs `/v1/messages` with `stream: true`. We do not adopt `github.com/anthropics/anthropic-sdk-go` in v1 — the API surface we use (one streaming POST + tool‑use loop) is small enough that a hand‑rolled client is shorter than wiring the SDK and safer than depending on a Go SDK that has historically broken on minor version bumps in mAi's experience. Keep the option open for Phase 2 if the token‑accounting / structured tool‑use helpers in the SDK become attractive.
|
||||
|
||||
```go
|
||||
// internal/services/paliadin/anthropic.go
|
||||
type AnthropicClient interface {
|
||||
Stream(ctx context.Context, req MessagesRequest, w StreamWriter) (Usage, error)
|
||||
}
|
||||
```
|
||||
|
||||
The interface is the only swap‑point. Switching providers later means a new implementation, not a rewrite.
|
||||
|
||||
### 2.2 System prompt + message shape (Q2)
|
||||
|
||||
**Recommendation: single `system` prompt with paliad context + tool definitions; one persistent prompt across pages (no per‑route system prompts in v1).**
|
||||
|
||||
#### 2.2.1 System prompt (locked, v1)
|
||||
|
||||
The system prompt is computed at process start from `branding.Name`, the user's locale (DE/EN), the user's `display_name`, the current date, and the visible‑project count (a single count, not the project list — keeps the prompt small). Computed *per request*, not per process — but its template is a constant.
|
||||
|
||||
```
|
||||
You are Paliadin, an AI assistant inside {{firm}}'s patent practice
|
||||
platform "Paliad". You help {{display_name}} ({{office}}) answer
|
||||
questions about their own work in Paliad and about UPC / EPO / DPMA
|
||||
patent practice.
|
||||
|
||||
Today is {{today}}. The user's display language is {{language}}; reply
|
||||
in {{language}} unless the user switches mid‑conversation.
|
||||
|
||||
You have read‑only access to the following tools:
|
||||
- whats_on_my_plate — the user's dashboard (deadline / appointment / matter buckets)
|
||||
- list_my_projects — every project the user can see
|
||||
- get_project_detail — full detail of one project (deadlines, appointments, parties, partner units)
|
||||
- search_my_deadlines — filter the user's deadlines by status / date / project
|
||||
- list_my_appointments — the user's upcoming appointments (next 30 days by default)
|
||||
- lookup_court — Paliad's catalog of patent courts (UPC LDs, German LGs/OLGs/BGH, EPO, DPMA, ...)
|
||||
- lookup_glossary_term — Paliad's bilingual patent glossary
|
||||
- lookup_deadline_rule — Paliad's Fristenrechner concept tree (named deadline rules + their triggers)
|
||||
|
||||
Hard rules:
|
||||
1. Never invent facts. If a tool returns nothing, say so. Do not guess
|
||||
case numbers, deadline dates, court names, or party names.
|
||||
2. Every concrete factual claim about the user's work MUST come from a
|
||||
tool call in the current conversation. Cite using "[#deadline-XXXX]",
|
||||
"[#projekt-XXXX]", "[court: Munich LD]", "[glossary: Klageerwiderung]"
|
||||
so the UI can render citation chips.
|
||||
3. You cannot mutate any data. If the user asks you to change something,
|
||||
explain that v1 is read‑only and point them to the right page in
|
||||
Paliad.
|
||||
4. Visibility is enforced before tools return — if your tool call comes
|
||||
back empty, the data either doesn't exist OR the user can't see it.
|
||||
Never disclose the latter; just answer "I couldn't find anything
|
||||
matching that".
|
||||
5. You cannot answer questions about other users' projects, even if the
|
||||
user names them.
|
||||
6. Respect the user's role. If the user has global_role=standard, do not
|
||||
speculate about admin‑only functions.
|
||||
|
||||
Style:
|
||||
- Direct, professional, slightly warm. Lawyer‑adjacent.
|
||||
- Reply in Markdown. Use lists, code blocks, blockquotes.
|
||||
- Cite specifically (case numbers, dates, court names) — never "around
|
||||
the 14th".
|
||||
- When uncertain, flag it. ("I don't see a deadline matching that
|
||||
description on the projects you can access.")
|
||||
- No emojis unless the user uses one first.
|
||||
|
||||
You are NOT:
|
||||
- A code‑writing assistant
|
||||
- A replacement for legal advice
|
||||
- A web search
|
||||
```
|
||||
|
||||
This is ~250 input tokens — well under the budget.
|
||||
|
||||
#### 2.2.2 Per‑message envelope
|
||||
|
||||
The browser POSTs to `/api/paliadin/turn` with `{ session_id, user_message, history }`, where `history` is the prior turns *in the current session only* (session = browser tab; localStorage backs it). The server prepends the system prompt and runs the tool‑use loop.
|
||||
|
||||
#### 2.2.3 Tool use vs RAG‑only (Q2 secondary)
|
||||
|
||||
**Tool use, not RAG.** RAG (vector search over chunks of paliad content) is the wrong shape for this surface — paliad data is highly structured, the most useful answers come from filtered SQL queries (e.g. "all deadlines on my projects with `status='pending'` and `due_date<=now()+7d`"), and a vector store would just paraphrase what an SQL query returns more accurately. Tools give the model the same query power the user has, with hard visibility gates. Phase 2 may add RAG over a small static corpus (HL Patents Style guide, Paliadin docs) if onboarding queries don't get good answers from glossary lookups alone.
|
||||
|
||||
### 2.3 Long‑lived service vs lexy‑style worker spawn (Q4)
|
||||
|
||||
**Recommendation: long‑lived Go service (in‑process) — *not* a per‑session Claude Code worker.**
|
||||
|
||||
| Option | Latency to first token | Cost / turn | Operational shape |
|
||||
|---|---|---|---|
|
||||
| In‑process Go service calling Anthropic API directly | < 1 s (just network + queueing) | Pay only for the model tokens we use | Single binary, single Postgres conn, scales with paliad |
|
||||
| `mai hire paliadin` per session (Claude Code worker) | 5–15 s | Worker startup overhead × N concurrent sessions × Claude Code's own context overhead | Operational footprint of running a worker per active user — dozens of tmux panes, tasks, reports |
|
||||
|
||||
The lexy / cassandra worker pattern works because it's *batch*: classify N judgments, emit JSON, exit. A chat surface needs sub‑second response times across dozens of HLC users in parallel. A Claude‑Code‑per‑session pattern would give each user their own Claude in the loop, with all the tooling and message‑bus scaffolding that implies — wrong scale of abstraction.
|
||||
|
||||
**That said, two things from the worker pattern do carry over:**
|
||||
1. **System‑prompt voice.** The lexy / mai-lexy SKILL.md persona ("Sharp, analytical, direct. Cites provisions and case law naturally. Flags uncertainty honestly.") is the right voice for Paliadin. We borrow it — see §2.2.1.
|
||||
2. **Tool catalog shape.** The lex-research SKILL.md tool list (search → fetch full text → enrich → analyse → cite) maps cleanly onto Paliadin's read tools — see §3.
|
||||
|
||||
### 2.4 mlex / `/lex-*` carry‑over map (Q3, Q18)
|
||||
|
||||
**Inventory result, with the shape‑vs‑code split called out for each:**
|
||||
|
||||
| Skill / asset | What it does | Carry‑over to Paliadin |
|
||||
|---|---|---|
|
||||
| `~/dev/mLex/` (workspace) | `extractions/` (per‑case JSON), `analysis/` (markdown reports), `docs/` (legal references), `extractions/queue.json` | **None as code.** Workspace artifacts are the *output* of the skills — they don't give us anything embeddable. |
|
||||
| `lex-research` skill | UPC case law search → analysis report. Tool catalog: `mcp__supabase__execute_sql`, `mcp__youpc__*`, `mcp__youpc-memory__*`. Output format: structured markdown with citation tables. | **Voice + tool‑catalog shape.** "Search → enrich → analyse → cite" is the Paliadin flow. The skill's output‑format conventions (case number on first mention, division comparison tables) seed the system prompt's style guidance. |
|
||||
| `lex-extract` skill | Read full judgment text → structured holdings / principles / interpretations JSON. | **Not v1.** Phase 2 candidate iff Paliadin gets a `extract_judgment(node_id)` write tool — orthogonal to read‑only v1. |
|
||||
| `lex-classify` skill | Classify judgments against a 47‑leaf taxonomy. | **Not v1.** Same as above — write‑surface, batch‑shaped, irrelevant to interactive Q&A. |
|
||||
| `lex-classify-patent` skill | Classify patents into IPC technology sectors via Anthropic. | **Pattern reference only.** It's already an Anthropic‑backed pipeline, so its prompt structure is a working example we can crib from for the system‑prompt template — but the actual classification target is paliad‑irrelevant. |
|
||||
| `mai-lexy` skill | Lawyer persona that orchestrates the above. "Citation‑backed, flags uncertainty." | **Voice template.** The persona text is the closest thing to a working Paliadin system prompt; §2.2.1 borrows directly from it. |
|
||||
| `claude-api` skill | Anthropic SDK / Messages API patterns + prompt caching guidance. | **Implementation reference for the Go client + caching strategy.** §6.4 picks up its prompt caching guidance. |
|
||||
|
||||
**Anti‑reuse:** the `mcp__youpc__*` MCP tools that `lex-research` uses are designed for an interactive Claude Code session. Paliadin's tools must instead be Go service calls — same data shape, different transport. Don't try to embed an MCP client in a paliad Go process; rebuild the same SQL queries against the same Postgres directly.
|
||||
|
||||
### 2.5 Tool catalog v1 (Q17)
|
||||
|
||||
Seven read‑only tools. Each is a thin Go shim around an existing service; each enforces visibility through that service's existing `visibilityPredicate`.
|
||||
|
||||
| Tool name | Backing service / method | Inputs | Output (truncated to fit budget) |
|
||||
|---|---|---|---|
|
||||
| `whats_on_my_plate` | `DashboardService.Get(userID)` | none | `{deadline_summary, appointment_summary, matter_summary, upcoming_deadlines[≤10], upcoming_appointments[≤10], recent_activity[≤10]}` |
|
||||
| `list_my_projects` | `ProjectService.ListVisible(userID, filter)` | optional `{status, kind}` | `[{id, kind, label, status, parent_id, path}]` paged 25 |
|
||||
| `get_project_detail` | `ProjectService.Get(userID, id) + DeadlineService.ListByProject + AppointmentService.ListByProject + PartyService.ListByProject + DerivationService.AttachedUnits` | `{project_id}` | `{project, deadlines[≤25], appointments[≤25], parties[≤10], partner_units[≤5]}` — 503 if user can't see it (LLM gets a clean "not found", same response as truly missing) |
|
||||
| `search_my_deadlines` | new helper on `DeadlineService` (reuses `visibilityPredicate`) | `{q?, status?, project_id?, due_after?, due_before?, limit≤25}` | `[{id, title, due_date, status, project_label, court}]` |
|
||||
| `list_my_appointments` | new helper on `AppointmentService` | `{from, to, project_id?}` | `[{id, title, start_at, end_at, location, project_label}]` |
|
||||
| `lookup_court` | `CourtService.Search(q)` (firm‑wide; no visibility filter — courts are reference data) | `{q}` | `[{slug, name, country, kind, address, vacation_periods[≤4]}]` truncated 10 |
|
||||
| `lookup_glossary_term` | static JSON loader (`internal/handlers/glossary.go` data) | `{q, lang?}` | `[{de, en, definition, category}]` top 5 |
|
||||
| `lookup_deadline_rule` | `DeadlineRuleService.SearchConcept(q)` | `{q}` | `[{rule_code, concept_label, trigger_event, deadline_text, legal_source}]` top 5 |
|
||||
|
||||
**Bumped out of v1 (Phase 2 candidates):**
|
||||
|
||||
- `list_my_pending_approvals` (the inbox bell payload) — useful but adds RLS surface; let v1 stabilise first.
|
||||
- `search_youpc_case_law` — m's framing example, but cross‑schema → bigger blast radius. Phase 2 once Paliadin proves its weight on paliad‑internal data.
|
||||
- `search_my_audit_log` — high signal but PII heavy.
|
||||
- `compute_frist` — would invoke the existing `DeadlineCalculator`. Useful but the user can already do this on `/tools/fristenrechner`; defer until we see queries that actually want it.
|
||||
- All write tools (`create_deadline`, `attach_partner_unit`, etc.) — Phase 3 minimum, with hard confirmation gate (see §6).
|
||||
|
||||
### 2.6 The tool‑use loop (Q2 tertiary)
|
||||
|
||||
Standard Anthropic tool‑use loop:
|
||||
|
||||
```
|
||||
1. Build messages = [system, ...history, user_message]
|
||||
2. POST /v1/messages with tools=[...catalog]
|
||||
3. Stream assistant reply chunks → relay to client SSE
|
||||
4. If stop_reason == "tool_use":
|
||||
for each tool_use block:
|
||||
execute tool(input) on the matching Go service
|
||||
emit tool_result block back into messages
|
||||
goto 2 (with the same stream/SSE connection)
|
||||
5. If stop_reason == "end_turn": close stream
|
||||
```
|
||||
|
||||
**Hard cap on the loop:** ≤ 5 tool‑call rounds per turn. After 5 rounds without `end_turn`, force‑close with "Sorry, I got stuck — try rephrasing." Hitting the cap is a UI red flag we want to see in audit (see §6.3).
|
||||
|
||||
---
|
||||
|
||||
## §3 Sub-design B — Data access, RLS, PII
|
||||
|
||||
Answers Q5, Q6, Q7.
|
||||
|
||||
### 3.1 Knowledge sources for v1 (Q5)
|
||||
|
||||
**Recommendation: paliad‑internal data + paliad's static reference data ONLY. youpc.org case law deferred to Phase 2.**
|
||||
|
||||
| Source | v1 | Reason |
|
||||
|---|---|---|
|
||||
| **Per‑user paliad data** (deadlines, appointments, projects, parties, partner units, attached units) | ✅ | The whole point of Paliadin. Visibility enforced via `visibilityPredicate` (every backing service already does this; tool inherits it). |
|
||||
| **Static reference data** in paliad (court catalog t‑122, glossary, deadline rules, Fristenrechner concept tree) | ✅ | Firm‑wide, no per‑user gating, low blast radius. |
|
||||
| **UPC case law** (youpc Postgres `data.judgments`, `data.judgment_markdown_content`) | ❌ Phase 2 | Cross‑schema SELECT is technically trivial (same Postgres) but: (a) inflates the v1 surface; (b) brings in 1700+ judgments → scaling RAG/full‑text question; (c) m's framing called out research as a *use case*, not a v1 must‑have. Ship paliad‑internal Q&A first; layer case‑law on once the substrate is proven. |
|
||||
| **HL Patents Style guide / Paliad onboarding docs** | ❌ Phase 2 | No internal corpus exists yet; would need docs‑authoring + indexing. The `lookup_glossary_term` tool already covers the most common onboarding question shape ("was bedeutet X?"). |
|
||||
| **External web search** | ❌ | Out of scope; Paliadin is a *grounded* assistant, not a web surfer. m can use the regular Claude for that. |
|
||||
|
||||
**Ranking inside the v1 set (when Paliadin has to choose):**
|
||||
|
||||
1. User‑data tools first when the question references "my", "the case", "the deadline", or names a project / case number that resolves.
|
||||
2. Static reference next when the question is conceptual ("what's a Klageerwiderung?", "which court is the Munich LD?").
|
||||
3. Combine when both apply ("when is my Klageerwiderung due?" → `lookup_deadline_rule` for the rule + `search_my_deadlines` for the user's instance).
|
||||
|
||||
The system prompt names tools in this priority order; the model's tool‑selection follows.
|
||||
|
||||
### 3.2 Auth / visibility boundary (Q6)
|
||||
|
||||
**The gate:** every backing service already runs `visibilityPredicate(alias)` against the caller's UUID. The Paliadin tool shim is a 5‑line wrapper that calls the service with `userID` derived from `auth.UserIDFromContext(r.Context())` at the SSE handler boundary. There is no service‑role escape — the shim simply has no other UUID to pass in.
|
||||
|
||||
**Belt‑and‑braces:** every tool result is inspected for `project_id` columns; for each distinct `project_id`, the shim asserts `paliad.can_see_project(_project_id)` returns `true`. (Defence‑in‑depth: catches any future service‑layer regression where someone forgets the predicate. Costs one extra cheap function call per tool turn; cheap.)
|
||||
|
||||
**The "tell, don't disclose" rule (§2.2.1 hard‑rule 4):** if the user names a project they cannot see, the tool returns `{error: "not found"}` — same response as a project that doesn't exist. The system prompt instructs the model to say "I couldn't find anything matching that" without distinguishing the two cases. This is the same rule the t‑144 ViewService already applies.
|
||||
|
||||
**Cross‑user PII in tool outputs:** tool outputs may legitimately contain other users' display names (e.g. project teams, deadline assignees). These are visible to the caller through the regular UI already, so disclosing them through Paliadin is no worse. We do NOT redact them.
|
||||
|
||||
**Approval / partner‑unit derivation:** `get_project_detail` returns the derived team (per t‑139 `DerivationService.AttachedUnits`). Same predicate as the rest of the app.
|
||||
|
||||
### 3.3 PII handling, retention, encryption (Q7)
|
||||
|
||||
**v1 stance: minimum viable persistence, maximum auditability of the access pattern.**
|
||||
|
||||
| Data | Stored where | Retention | Encryption | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Conversation history (the actual messages) | **Browser localStorage only.** Cleared on browser data wipe / reload‑with‑fresh‑session. | Session only | n/a | Phase 2: opt‑in DB persistence with retention controls. |
|
||||
| Per‑request audit row | New `paliad.paliadin_turns` table | Forever (matches audit‑log pattern; soft‑delete only) | At‑rest by Postgres / Supabase volume encryption | Stores: `turn_id, user_id, started_at, finished_at, model, input_tokens, output_tokens, tool_calls (jsonb of tool names + arg hashes — NOT arg values), prompt_hash (sha256 of redacted user message), error_code`. **No prompt body, no completion body.** |
|
||||
| Tool‑call inputs (e.g. project_id arguments) | Hashed (sha256) into the audit row's `tool_calls` jsonb | Forever | n/a | The hash is enough to detect "this user kept asking about project X" patterns without storing the readable id. |
|
||||
| Anthropic API request/response bodies | **Not stored.** Streamed through the Go service straight to the SSE writer. | n/a | TLS in flight | Anthropic's own retention is governed by the org's API contract — pulling Paliad onto an existing HLC enterprise key would inherit that. |
|
||||
|
||||
**Why this shape:**
|
||||
|
||||
- **Compliance‑lite v1.** HLC's compliance team has not yet weighed in on AI‑mediated PII (memory says the Phase H decision was "we don't want anthropic API… for a while"). Storing the full transcript opens a retention/disclosure question we don't need to answer to ship Paliadin's MVP. The audit‑metadata row is enough to demonstrate: (a) who used it, (b) how often, (c) what tools they triggered, (d) cost.
|
||||
- **Phase 2 transcript persistence** would add a `paliadin_messages` table (turn_id FK, role, content, redact_marks jsonb) and a per‑user setting "keep my history". Default off.
|
||||
- **Why no PII redaction in the user prompt?** v1 is opt‑in (the user typed the prompt). Redacting client names / case numbers in the audit hash would defeat the point; we redact by *not storing the prompt*, only its hash.
|
||||
|
||||
**The Anthropic side:** if HLC's enterprise contract forbids vendor‑side retention, the Go client must set `metadata: {user_id: "<hash>"}` and ensure the API call is on an org with zero‑retention guarantees. **Open question for m: which Anthropic key are we using — m's personal key (existing `ANTHROPIC_API_KEY` precedent in mAi/youpcms) or a new HLC enterprise key?** This is the single biggest compliance question; see §9.2.
|
||||
|
||||
---
|
||||
|
||||
## §4 Sub-design C — UX
|
||||
|
||||
Answers Q8, Q9, Q10, Q11, Q12.
|
||||
|
||||
### 4.1 Surface placement (Q8)
|
||||
|
||||
**Recommendation (counter to brief): start with a dedicated `/paliadin` full‑page route + a sidebar entry under the "Übersicht" group. Defer the right‑drawer to Phase 2.**
|
||||
|
||||
| Option | v1? | Why |
|
||||
|---|---|---|
|
||||
| **`/paliadin` full page** + sidebar entry | ✅ | Lowest CSS risk; mobile‑responsive for free (paliad's existing breakpoints work); easy to test via Playwright; matches paliad's "every feature is a top‑level page" pattern; no z‑index / overlay debugging. |
|
||||
| Right‑drawer slide‑out from any page | ❌ Phase 2 | Pretty, matches m's "panel docked into UI" framing — but adds: drawer toggle wiring on all 30 pages, scroll‑lock interaction, focus management, mobile small‑screen fallback. Not worth the v1 surface area. Phase 2 wraps the same `/paliadin` UI in a slide‑out container. |
|
||||
| Floating bottom‑right bubble | ❌ | Clippy comparison is *visual*, not *positional*. A floating overlay on every page collides with the BottomNav on mobile (already 5/5 slots) and the inbox bell on desktop. |
|
||||
| Page‑embedded panel on `/paliadin` only | — | This *is* the v1 recommendation, just framed differently. |
|
||||
|
||||
**Sidebar entry:**
|
||||
|
||||
```
|
||||
Übersicht
|
||||
Start
|
||||
Agenda
|
||||
Inbox 🛎
|
||||
Paliadin ✨ ← new, ICON_SPARKLE
|
||||
```
|
||||
|
||||
Group placement under Übersicht (not under Tools or Wissen) because Paliadin is conversation about *the user's work*, not a knowledge tool.
|
||||
|
||||
**Mobile:** Paliadin is reachable via the sidebar drawer (existing mobile pattern). No BottomNav slot — those are full and the ranking (Start / Projekte / + / Agenda / Menü) is more important than a chat shortcut for v1.
|
||||
|
||||
### 4.2 Avatar / personality (Q9)
|
||||
|
||||
**Recommendation: no avatar SVG in v1. Just a chat panel with the name "Paliadin" in the header. Mascot is Phase 2.**
|
||||
|
||||
Why:
|
||||
|
||||
- Mascot design is a real design exercise (3–4 iterations to get something that doesn't read as kitsch in a law firm). Not inventor's call to bash one out in a v1 ship.
|
||||
- The brand cue (lime‑green `#c6f41c` accent) is enough to make Paliadin feel like part of paliad without a character.
|
||||
- Paliadin's *personality* lives in the system prompt (§2.2.1), not in pixels. Voice carries the buddy framing; mascot makes it visual but isn't load‑bearing.
|
||||
|
||||
What we ship in v1 instead:
|
||||
|
||||
- Header: "✨ Paliadin" (sparkle icon + name) above the chat panel.
|
||||
- Empty‑state prompt: "Was kann ich für dich tun?" (DE) / "How can I help?" (EN).
|
||||
- One‑line tagline under the header: "Ich kenne deine Akten und Paliads Wissensbasis." (DE) / "I know your matters and Paliad's knowledge base." (EN). This is the *only* v1 affordance that explicitly tells the user "I see your data" — load‑bearing for the differentiation argument in §0/§9.1.
|
||||
|
||||
**Phase 2 mascot brief (for when m greenlights it):** small SVG, friendly, lime‑green primary, no eyes‑darting / animated‑on‑idle (creepy), modular pose set so it can react to "thinking" / "found it" / "stuck" without being an MMORPG pet.
|
||||
|
||||
### 4.3 Onboarding hint (Q10)
|
||||
|
||||
**Recommendation: silent‑until‑invoked. No proactive pop‑up, no first‑run modal, no toast.**
|
||||
|
||||
Why:
|
||||
|
||||
- Paliad already has a polished onboarding flow (t‑paliad‑034). Adding a Paliadin pop‑up on top would be the kind of "surprise the user" affordance that erodes trust the first time it misfires.
|
||||
- The empty‑state inside `/paliadin` itself is the right onboarding surface: 3 starter‑prompt buttons rendered when the chat is empty.
|
||||
|
||||
**Three starter prompts (DE primary):**
|
||||
|
||||
1. "Was steht heute an?" → triggers `whats_on_my_plate`
|
||||
2. "Welche Fristen sind diese Woche fällig?" → triggers `search_my_deadlines` with `due_before=now()+7d`
|
||||
3. "Erkläre mir Klageerwiderung." → triggers `lookup_glossary_term` + `lookup_deadline_rule`
|
||||
|
||||
EN equivalents: "What's on my plate?" / "Which deadlines are due this week?" / "Explain Klageerwiderung."
|
||||
|
||||
Picking one from the row sends it as if the user typed it. Keeps the surface zero‑weight when ignored.
|
||||
|
||||
**Phase 2 candidate:** post‑onboarding email / inbox card "Paliadin ist live, frag ihn was deine Daten dir sagen." Driven by the existing reminder/email substrate. Out of v1 scope.
|
||||
|
||||
### 4.4 Action chips in responses (Q11)
|
||||
|
||||
**Recommendation: action chips parsed from a simple inline syntax in the model's reply, rendered client‑side, NOT a tool the model invokes.**
|
||||
|
||||
Why simple syntax over a tool: tool invocations cost a round‑trip; we want the model to "suggest" an action without paying for an extra tool turn. The model emits a structured marker in its prose; the frontend client parses it and renders a chip below the bubble.
|
||||
|
||||
**Marker format:**
|
||||
|
||||
```
|
||||
[#deadline-OPEN:c47bd2]
|
||||
[#projekt-OPEN:slug-x]
|
||||
[#frist-OPEN:c47bd2]
|
||||
[#termin-OPEN:abc123]
|
||||
[chip:nav:/projects/abc-123] (for arbitrary navigation)
|
||||
[chip:filter:status=pending&due=this_week] (for parameterised inbox links)
|
||||
```
|
||||
|
||||
The system prompt teaches the model to emit chips when navigation or filtering would help the user act on the answer. Each marker resolves to one chip, rendered as:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Frist 16.05.2026 fällt morgen. │
|
||||
│ [Frist öffnen] [Akte ansehen] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Client parser** (`frontend/src/client/paliadin.ts`): regex over the streamed text, replaces marker with a button. Buttons are real `<a>` elements (Cmd‑click works, keyboard works), styled like the existing `.entity-table` row chips.
|
||||
|
||||
**Why not let the model embed full URLs?** Two reasons:
|
||||
1. URLs change (we renamed `/akten` → `/projekte` mid‑project). Markers are stable; we resolve them at render time.
|
||||
2. Hallucinated URLs are real risk. If the model can only emit a marker tied to an id we *know* it just retrieved, the chip can't navigate to a fake page.
|
||||
|
||||
### 4.5 Streaming + interruption (Q12)
|
||||
|
||||
**Recommendation: SSE stream from `/api/paliadin/stream`, client EventSource, user‑initiated abort via "Stop" button.**
|
||||
|
||||
#### 4.5.1 Stream shape
|
||||
|
||||
Mirrors Anthropic's native streaming events, adapted for our SSE consumer:
|
||||
|
||||
```
|
||||
event: meta
|
||||
data: {"turn_id":"01H…","model":"claude-sonnet-4-6"}
|
||||
|
||||
event: content_delta
|
||||
data: {"text":"Auf der Akte Müller…"}
|
||||
|
||||
event: tool_call
|
||||
data: {"name":"search_my_deadlines","args_hash":"…","status":"running"}
|
||||
|
||||
event: tool_result
|
||||
data: {"name":"search_my_deadlines","status":"ok","summary":"3 results"}
|
||||
|
||||
event: content_delta
|
||||
data: {"text":"… ist die Klageerwiderung am 16.05. fällig."}
|
||||
|
||||
event: chip
|
||||
data: {"kind":"deadline","action":"open","id":"c47bd2"}
|
||||
|
||||
event: end
|
||||
data: {"input_tokens":342,"output_tokens":88,"tool_calls":1}
|
||||
|
||||
# heartbeat every 25 s to keep Traefik from reaping
|
||||
event: ping
|
||||
data: {}
|
||||
```
|
||||
|
||||
The `tool_call` / `tool_result` events are visible in the UI as small dim "ran search_my_deadlines (3 results)" lines under the bubble — the **citation evidence** that distinguishes Paliadin from a generic chatbot. (Direct quote from the §0 framing: "the differentiation collapses if v1 doesn't make the data‑grounding visible.")
|
||||
|
||||
#### 4.5.2 Interruption
|
||||
|
||||
- "Stop" button next to the input. Click → `EventSource.close()` + `fetch('/api/paliadin/stream/{turn_id}/abort', {method:'POST'})`.
|
||||
- Server abort closes the upstream Anthropic request via context cancellation.
|
||||
- Stopped turns still write an audit row with `error_code='user_aborted'` so we see how often users hit it.
|
||||
|
||||
#### 4.5.3 Reconnect
|
||||
|
||||
Same Last‑Event‑ID resume pattern the t‑145 chat design specced. Server keeps the in‑flight stream buffered for 30 s after disconnect; reconnect within that window replays missed events. After 30 s, the turn is considered done — reconnect arrives at the start of a fresh session.
|
||||
|
||||
---
|
||||
|
||||
## §5 Sub-design D — Token budget, cost, audit
|
||||
|
||||
Answers Q13, Q14, Q15, Q16.
|
||||
|
||||
### 5.1 Per‑request token cap (Q13)
|
||||
|
||||
**Recommendation: `max_input_tokens=4000` (model's view of input including system + history + tool defs + user msg) and `max_tokens=2000` (model's max output) — same as brief. Hard‑fail above; soft‑truncate history below.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- A typical paliad data tool result is < 500 tokens (truncated lists, capped at 25 rows). Even with system prompt (~250) + tool defs (~600) + 5 prior turns (~600 each on average) the input stays well under 4 k.
|
||||
- If the conversation runs long (~8+ turns), the client/server soft‑truncates history (drops oldest user/assistant pairs first) before sending. The user sees a "Earlier in this conversation, we discussed X (truncated)" pseudo‑system message. Cleaner than failing the turn.
|
||||
- Hard cap at 6 k input tokens — over that, refuse the turn with "Conversation too long, start a new one." Defends against jailbreak attempts that try to balloon the prompt.
|
||||
|
||||
**Cost math at Sonnet 4.6 per‑turn typical (3 k input, 1 k output):** ~$0.012/turn. At 30 turns/hour/user × 38 onboarded HLC users × 5 working hours/day = ~5 700 turns/day = **~$70/day worst case**. Realistic load is probably 10× lower. Phase 2: prompt caching (§5.4) drops it further.
|
||||
|
||||
### 5.2 Conversation history persistence (Q14)
|
||||
|
||||
**Recommendation: session‑only in v1. Persistent threads in Phase 2.**
|
||||
|
||||
| Option | v1? | Why |
|
||||
|---|---|---|
|
||||
| Session‑only (browser localStorage, cleared on tab close + Sign Out) | ✅ | Zero schema. Zero retention question. Aligns with §3.3 "minimum viable persistence." Lets us ship paliadin without compliance review of stored transcripts. |
|
||||
| Persistent threads (DB‑stored, named) | ❌ Phase 2 | Real schema (`paliadin_threads`, `paliadin_messages`), retention policy, cross‑device sync, "delete my history" UX, possibly opt‑in toggle. None of which is needed to validate "is Paliadin actually useful". |
|
||||
|
||||
**Edge case: page reload during a conversation.** localStorage persists the history *for that browser tab*. Closing and reopening the tab restores. Closing the browser & reopening also restores. Sign‑out clears. Multi‑device = different histories. We're explicit about this in the panel header: "Conversation lives in this browser only" tooltip.
|
||||
|
||||
**Why opt for slightly worse UX over the easy schema work:** the t‑paliad‑145 chat just got parked over an *adoption*‑risk concern, not a schema concern. Paliadin should ship the smallest possible footprint that proves usefulness. Persistent threads can be a "you asked for this" Phase 2.
|
||||
|
||||
### 5.3 Rate limit per user (Q15)
|
||||
|
||||
**Recommendation: 30 turns/hour/user (slightly tighter than the brief's 50). Plus a global ceiling of 1 000 turns/hour across the firm. Both configurable.**
|
||||
|
||||
Per‑user 30/hour because:
|
||||
|
||||
- 30/hour ≈ one turn every two minutes during sustained use. That's heavy use. A reasonable user asks 3–5 questions in a session.
|
||||
- Soft hint at 25 ("you've used 25 of 30 messages this hour"), hard block at 30 with retry‑after.
|
||||
- Lower than 50 to give us a safety margin for runaway cost in week 1; we can raise it once we see real usage.
|
||||
|
||||
Global 1 000/hour ceiling because:
|
||||
|
||||
- Global cap = circuit breaker against the long tail (a script that sends 1000 turns/hour from one user we missed in the per‑user cap, or a developer bug).
|
||||
- 1 000 turns × ~$0.012 = $12/hour worst case = $288/day. We tolerate that for a day; we'd notice and tune.
|
||||
|
||||
**Storage:** simple Postgres `paliad.paliadin_rate_limit` table with `(user_id, hour_bucket, turn_count)` upserted on every turn start. No Redis, no extra dependency. Fast at this scale.
|
||||
|
||||
**Admin override:** global_admin can lift their own cap (they typically test things). Surface this in the audit row, not in a CLI.
|
||||
|
||||
### 5.4 Audit + logging (Q16)
|
||||
|
||||
**Recommendation: every turn writes a metadata‑only row to `paliad.paliadin_turns`. Full transcripts are NOT stored in v1. Tool‑call args are hashed. Anthropic vendor side is governed by org‑level retention.**
|
||||
|
||||
#### 5.4.1 Schema (migration 057)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.paliadin_turns (
|
||||
turn_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
session_id text NOT NULL, -- browser session, opaque
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz, -- NULL until end‑of‑turn
|
||||
model text NOT NULL, -- e.g. 'claude-sonnet-4-6'
|
||||
input_tokens int, -- from Anthropic usage block
|
||||
output_tokens int,
|
||||
tool_calls jsonb NOT NULL DEFAULT '[]', -- [{name, args_hash, status, latency_ms}]
|
||||
prompt_hash text, -- sha256 of user_message after PII redaction (best effort)
|
||||
response_hash text, -- sha256 of full response (citation only, not stored)
|
||||
chip_count int NOT NULL DEFAULT 0,
|
||||
error_code text, -- NULL on success; 'user_aborted', 'rate_limited', 'token_cap', 'tool_loop_cap', 'upstream_error'
|
||||
estimated_cost_usd numeric(10, 6) -- for ops dashboards
|
||||
);
|
||||
|
||||
CREATE INDEX paliadin_turns_user_started_idx
|
||||
ON paliad.paliadin_turns(user_id, started_at DESC);
|
||||
CREATE INDEX paliadin_turns_started_idx
|
||||
ON paliad.paliadin_turns(started_at DESC);
|
||||
|
||||
ALTER TABLE paliad.paliadin_turns ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- User sees their own; global_admin sees all.
|
||||
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; no user‑direct INSERT.
|
||||
-- (Paliad uses service-role conn, so policies on writes are inert,
|
||||
-- but we still ENABLE RLS so future direct‑auth callers are gated.)
|
||||
```
|
||||
|
||||
Rate‑limit table also lives in this migration:
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.paliadin_rate_limit (
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
hour_bucket timestamptz NOT NULL,
|
||||
turn_count int NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, hour_bucket)
|
||||
);
|
||||
```
|
||||
|
||||
#### 5.4.2 What we DON'T store (v1)
|
||||
|
||||
- The user's actual prompt text. Only `prompt_hash`.
|
||||
- The model's actual response text. Only `response_hash`.
|
||||
- The tool inputs. Only `tool_calls[].args_hash`.
|
||||
|
||||
**Phase 2 transcript persistence** unlocks all three — deliberately separate migration so the compliance review sits at *that* boundary.
|
||||
|
||||
#### 5.4.3 Vendor retention
|
||||
|
||||
The Anthropic side is governed by the org‑level contract. **Open question for m (§9.2):** does HLC have an enterprise / zero‑retention agreement, or are we using m's personal key (matches existing `ANTHROPIC_API_KEY` precedent in mAi/youpcms)? The answer changes whether v1 needs a "data sent to Anthropic" disclosure on first use.
|
||||
|
||||
#### 5.4.4 Prompt caching (Phase 2)
|
||||
|
||||
The Anthropic API supports prompt caching for repeated system prompts + tool definitions. Our system prompt + 7 tool defs is ~850 tokens — perfect cache target. Phase 2: enable cache_control on the system block; cuts input cost by ~90% on repeat turns within the 5‑minute cache window. Skip in v1 to keep the client minimal; pick up after the API surface stabilises.
|
||||
|
||||
---
|
||||
|
||||
## §6 Schema, endpoints, files
|
||||
|
||||
### 6.1 New endpoints
|
||||
|
||||
| Method | Path | Purpose | Auth |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/paliadin/turn` | Initiate a turn — assigns `turn_id`, opens SSE | logged‑in (302 to /login otherwise) |
|
||||
| `GET` | `/api/paliadin/stream/{turn_id}` | SSE stream of the turn's response (mostly invoked from the same `POST` to keep the connection live; separate GET supports reconnect) | logged‑in |
|
||||
| `POST` | `/api/paliadin/stream/{turn_id}/abort` | User cancels mid‑turn | logged‑in, must own the turn |
|
||||
| `GET` | `/api/paliadin/limits` | Returns `{used_this_hour, hourly_cap, global_cap, global_used}` | logged‑in |
|
||||
| `GET` | `/paliadin` | The page shell (server‑renders the panel + initial empty state) | logged‑in |
|
||||
| `GET` | `/admin/paliadin` | Per‑user usage / cost dashboard | global_admin |
|
||||
|
||||
The `POST /api/paliadin/turn` returns `{turn_id, sse_url}`; the client opens an `EventSource` on `sse_url`. Two‑step keeps the POST cheap for telemetry / audit row creation, while the long‑lived stream lives on a GET that's safe to retry / resume.
|
||||
|
||||
### 6.2 New / extended services
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `internal/services/paliadin/service.go` | NEW | The orchestrator: run loop, history truncation, rate‑limit check, audit‑row writer |
|
||||
| `internal/services/paliadin/anthropic.go` | NEW | Hand‑rolled Messages API client (POST `/v1/messages`, stream parser) |
|
||||
| `internal/services/paliadin/tools.go` | NEW | Tool catalog declaration + dispatch into existing services |
|
||||
| `internal/services/paliadin/prompt.go` | NEW | System prompt template + per‑turn assembly |
|
||||
| `internal/handlers/paliadin.go` | NEW | HTTP / SSE handlers |
|
||||
| `internal/services/deadline_service.go` | extend | Add `SearchVisible(userID, q, status, projectID, dueAfter, dueBefore, limit)` (currently search is only on the global Fristenrechner matview) |
|
||||
| `internal/services/appointment_service.go` | extend | Add `ListVisibleInWindow(userID, from, to, projectID)` |
|
||||
| `internal/services/glossary_service.go` | NEW (or refactor of glossary handler data load) | A real service so the tool can call it; today it lives inline in the handler |
|
||||
|
||||
### 6.3 Frontend
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `frontend/src/paliadin.tsx` | NEW | Page shell |
|
||||
| `frontend/src/client/paliadin.ts` | NEW | Chat panel, EventSource, history serialise to localStorage, chip parser, "Stop" button |
|
||||
| `frontend/src/styles/global.css` | extend | New CSS section: `.paliadin-panel`, `.paliadin-bubble`, `.paliadin-bubble--user/--assistant/--tool`, `.paliadin-chip`, `.paliadin-input`, `.paliadin-meta` |
|
||||
| `frontend/src/components/Sidebar.tsx` | extend | Add Paliadin navItem to the Übersicht group with `ICON_SPARKLE` |
|
||||
| `frontend/src/i18n-keys.ts` | extend | ~25 new keys: `paliadin.title`, `paliadin.tagline`, `paliadin.starter.*`, `paliadin.empty`, `paliadin.input.placeholder`, `paliadin.stop`, `paliadin.rate_limited`, `paliadin.error.*` |
|
||||
|
||||
### 6.4 Migration 057
|
||||
|
||||
```
|
||||
057_paliadin.up.sql:
|
||||
- paliad.paliadin_turns (audit row, RLS, indexes)
|
||||
- paliad.paliadin_rate_limit (counter table, PK on user+hour)
|
||||
- GRANTs: service-role full, anon read disallowed by RLS
|
||||
057_paliadin.down.sql: drop both tables.
|
||||
```
|
||||
|
||||
### 6.5 Env vars (add to CLAUDE.md table)
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | for Paliadin | Anthropic Messages API key. **Replaces** the "do not set" row that referred to the parked Phase H. Without it, `/paliadin` returns 503 (server still boots; the rest of paliad keeps working). |
|
||||
| `PALIADIN_MODEL` | optional (default `claude-sonnet-4-6`) | Override model for tuning / fallback to Haiku for cost or Opus for accuracy without redeploying. |
|
||||
| `PALIADIN_HOURLY_CAP` | optional (default `30`) | Per‑user turn cap per hour. |
|
||||
| `PALIADIN_GLOBAL_HOURLY_CAP` | optional (default `1000`) | Firm‑wide turn cap per hour. |
|
||||
| `PALIADIN_MAX_INPUT_TOKENS` | optional (default `4000`) | Soft cap; over this we truncate history. |
|
||||
| `PALIADIN_MAX_OUTPUT_TOKENS` | optional (default `2000`) | Hard cap; passed straight to Anthropic. |
|
||||
|
||||
The Service must boot **without** `ANTHROPIC_API_KEY` (return 503 on `/paliadin*` routes; rest of paliad keeps working). Same pattern as `DATABASE_URL` and `CALDAV_ENCRYPTION_KEY`.
|
||||
|
||||
---
|
||||
|
||||
## §7 Sub-design E — Phasing (REVISED 2026-05-07 20:56)
|
||||
|
||||
Answers Q19, Q20. Two‑stage shape after m's re‑scope:
|
||||
|
||||
- **Phase 0 (PoC, m‑only):** §0.5 is the spec. ~600–900 LoC, ~1 day. Ships first.
|
||||
- **Phase 1 (production v1, multi‑user):** §7.1 below. Picked up only if §0.5.7's expansion gate fires.
|
||||
- **Phase 2 / 3:** unchanged.
|
||||
|
||||
### 7.1 Phase 1 (production v1) — confirmed scope, GATED on PoC success
|
||||
|
||||
**Single coherent slice that proves the value proposition end‑to‑end.**
|
||||
|
||||
| Item | In v1 |
|
||||
|---|---|
|
||||
| `/paliadin` page + sidebar entry under Übersicht | ✅ |
|
||||
| Migration 057 (`paliadin_turns` + `paliadin_rate_limit`) | ✅ |
|
||||
| Anthropic client (hand‑rolled, streaming) | ✅ |
|
||||
| 7 read‑only tools | ✅ |
|
||||
| System prompt with `branding.Name` + visibility rules | ✅ |
|
||||
| SSE stream with `meta`/`content_delta`/`tool_call`/`tool_result`/`chip`/`end`/`ping` events | ✅ |
|
||||
| Citation chips (parsed from inline markers) | ✅ |
|
||||
| Rate limiting (per‑user + global) | ✅ |
|
||||
| Audit row per turn (metadata only, no transcript) | ✅ |
|
||||
| Session‑only history (browser localStorage) | ✅ |
|
||||
| 3 starter prompts in DE+EN | ✅ |
|
||||
| Token caps + soft history truncation | ✅ |
|
||||
| `/admin/paliadin` cost dashboard (global_admin only) | ✅ |
|
||||
| ~25 i18n keys (DE+EN) | ✅ |
|
||||
| Mobile responsiveness (uses sidebar drawer like every other page) | ✅ |
|
||||
| CLAUDE.md update flipping the `ANTHROPIC_API_KEY` row | ✅ |
|
||||
|
||||
**Estimated scope:** ~3 500–4 500 LoC for the bundled v1 ship. Comparable to t‑144 (Custom Views) and t‑145's would‑have‑been chat slice.
|
||||
|
||||
**Single PR or split?** Recommend **single PR** for v1. The Anthropic client + tool dispatch + handler + frontend panel are too tightly coupled to ship one without the others — every component is on the critical path of "demonstrate Paliadin actually works". Splitting buys nothing review‑wise (no reviewer can validate "Anthropic client works" without "the tool dispatch that exercises it"). Use the same single‑PR pattern as t‑144 A1+A2 in retrospect.
|
||||
|
||||
### 7.2 Phase 2 candidates (post‑v1, prioritised)
|
||||
|
||||
In rough order of value:
|
||||
|
||||
1. **Persistent threads** + per‑user "keep my history" toggle. Adds `paliadin_threads` + `paliadin_messages` tables, retention policy, cross‑device sync. Compliance review attaches here, not to v1.
|
||||
2. **Prompt caching** for system prompt + tool defs. ~90 % input‑cost reduction on repeat turns. Pure server‑side change.
|
||||
3. **`search_youpc_case_law` tool.** Cross‑schema SELECT into `data.judgments` + `data.judgment_markdown_content`. Returns case number, division, date, headnote, top 3 holdings. The "research assistant" use case from m's framing.
|
||||
4. **Right‑drawer mode.** Wrap the `/paliadin` panel in a slide‑out container; toggle on every page from a header button.
|
||||
5. **Mascot SVG** + idle / thinking / found‑it pose set. Real visual design pass.
|
||||
6. **Onboarding tip** — post‑onboarding inbox card or one‑time toast on first dashboard visit after Paliadin lands.
|
||||
7. **`list_my_pending_approvals` tool.** Wraps inbox bell payload.
|
||||
8. **Voice input / output.** Web Speech API (paliad already has the substrate from the no‑Voice‑v1 t‑paliad‑042 PWA).
|
||||
|
||||
### 7.3 Phase 3 candidates (validate first)
|
||||
|
||||
- **Write tools.** `create_deadline`, `create_appointment`, `attach_partner_unit`, `add_party`. Each behind a hard confirmation gate ("Paliadin will create a deadline 16.05. on project X — confirm? [Yes / No]"). Audit‑row marks these as mutating turns. Heavy compliance question; not Phase 2.
|
||||
- **Per‑deadline / per‑termin micro‑threads.** Long‑lived per‑entity Q&A. Plumbing collision with the (parked) chat design — re‑evaluate when chat un‑parks.
|
||||
- **Proactive Paliadin.** Push tips when the user hits a known confused state ("You've been on /tools/fristenrechner for 8 minutes — want me to walk you through it?"). Powerful, but creepy if poorly tuned.
|
||||
- **Compliance‑aware redaction layer.** Strip client names from the prompt before it leaves the building, swap stable hashes back in client‑side. Big project; only sensible if HLC compliance forbids vendor‑side PII.
|
||||
|
||||
---
|
||||
|
||||
## §8 Risks, mitigations, open questions
|
||||
|
||||
### 8.1 Adoption risk (the §0 callout, expanded)
|
||||
|
||||
**The risk:** Paliadin competes with three things HLC already has:
|
||||
1. The user's own Claude / ChatGPT in another tab (for general patent‑practice questions).
|
||||
2. "Ask a colleague on Teams" (for paliad‑specific questions about how to use the app).
|
||||
3. Just clicking around the UI (for "what's on my plate today").
|
||||
|
||||
Paliadin's edge over (1) is data grounding. Edge over (2) is 24/7 + privacy. Edge over (3) is conversational discovery and answering one‑shot natural‑language queries that the structured UI doesn't expose.
|
||||
|
||||
**The risk realised:** if v1 doesn't make the data‑grounding visible (citation chips, tool‑call evidence under each bubble, the tagline "I see your data"), users default to ChatGPT for everything, and Paliadin becomes a ghost feature that ate 3 weeks of build. Same pattern that just parked t‑paliad‑145.
|
||||
|
||||
**Mitigations baked into v1:**
|
||||
|
||||
- **Tool‑call evidence visible** in every bubble. The user *sees* "ran search_my_deadlines (3 results)" — instant differentiation from a generic chatbot.
|
||||
- **Citation chips** make answers actionable, not just informative.
|
||||
- **Tagline + empty state** explicitly say "I see your projects."
|
||||
- **Three starter prompts** demonstrate the data‑grounding immediately on first use.
|
||||
|
||||
**Mitigations m should consider before approving:**
|
||||
|
||||
- **Sanity‑check with two PA colleagues** before locking v1 scope. Same recommendation t‑145 got. If two PAs say "I'd just open Claude in another tab", the scope shifts toward making the data‑grounding *more* prominent (e.g. ship "Paliadin sees only your data" as a persistent banner above the input, not a tooltip) before shipping at all.
|
||||
- **Soft launch + telemetry.** v1's audit row gives us cheap measurement of: (a) total turns/day, (b) turns per user, (c) tool‑call frequency (low = Paliadin is being used like ChatGPT, defeating the differentiation). Watch for two weeks; if tool‑calls/turn < 1.5 average, the feature isn't doing what we shipped it for and Phase 2 priorities change.
|
||||
|
||||
### 8.2 Compliance / vendor‑data risk
|
||||
|
||||
**The risk:** sending client names + case content to Anthropic's API may not be sanctioned by HLC IT/compliance. The 2026‑04‑16 "we don't want anthropic API… for a while" decision (memory `b6a11b55…`) was about *Frist extraction from documents*; Paliadin is conversational, but the data envelope sent to Anthropic still contains PII whenever a tool returns a project name.
|
||||
|
||||
**Mitigations:**
|
||||
|
||||
- **HLC enterprise key** (vs m's personal key) if available — gives org‑level retention + DPA coverage.
|
||||
- **Zero‑retention configuration** on the Anthropic call (`metadata: {user_id: "<hash>"}`, `cache_control` only on the system block, no `eval` enrolment).
|
||||
- **First‑use disclosure** in the panel: "Your messages and the data Paliadin retrieves on your behalf are sent to Anthropic. [Learn more]" — load‑bearing and required if the legal answer to §9.2 is "personal key, not enterprise".
|
||||
- **Phase 2 hardening:** server‑side redaction layer that swaps client names → stable hashes before the API call, restores them client‑side after. Big project; only sensible if compliance forbids vendor‑side PII.
|
||||
|
||||
### 8.3 Rate‑limit / runaway‑cost risk
|
||||
|
||||
**The risk:** a user (or a bug) loops fast enough to drain budget before alarms fire.
|
||||
|
||||
**Mitigations:**
|
||||
|
||||
- Per‑user 30/hour + global 1 000/hour caps (§5.3). Both surfaced on `/admin/paliadin`.
|
||||
- Per‑turn token cap (§5.1).
|
||||
- Per‑turn tool‑loop cap (≤ 5 rounds, §2.6).
|
||||
- Audit row written *before* the upstream call so a rate‑limit‑evading bug still leaves traces.
|
||||
- `PALIADIN_HOURLY_CAP` / `PALIADIN_GLOBAL_HOURLY_CAP` are env‑var configurable so we can tighten without a deploy.
|
||||
|
||||
### 8.4 Hallucination risk (model invents a deadline)
|
||||
|
||||
**The risk:** the model fabricates a deadline date / case number that doesn't exist in the user's data.
|
||||
|
||||
**Mitigations:**
|
||||
|
||||
- Hard rule in system prompt: "Every concrete factual claim about the user's work MUST come from a tool call in the current conversation."
|
||||
- Citation markers tied to tool‑result IDs only. Marker `#deadline-OPEN:c47bd2` resolves only if the id was returned by a real tool call this turn (frontend validates).
|
||||
- Tool‑call‑evidence visibility: the user can see that a tool ran and what it returned. Hallucination becomes obvious because the chip says "0 results" but the bubble claims a deadline.
|
||||
- **Phase 2:** server‑side post‑hoc validation that checks every cited id against the tool‑result set; reject the message and retry if the model invented one.
|
||||
|
||||
### 8.5 Open questions for m (REVISED 2026-05-07 20:56 for the PoC scope)
|
||||
|
||||
The re‑scope mooted most of the original questions. Tracking which are still active vs deferred:
|
||||
|
||||
**PoC‑relevant (decide before coder shift):**
|
||||
|
||||
1. **Q‑PoC‑1:** What goes in the system prompt's read‑recipe set? §0.5.3 says ~15 SQL recipes; the actual list is design‑level. Recommendation: start with `whats_on_my_plate`, `list_my_projects`, `get_project_detail`, `search_my_deadlines_by_status`, `lookup_court_by_name`, `lookup_glossary_term`, `lookup_deadline_rule_by_concept`. Same shape as §2.5, just expressed as SQL recipes Claude follows.
|
||||
2. **Q‑PoC‑2:** Does m want the response file (`/tmp/paliadin/{turn_id}.txt`) cleaned up after each turn (mVoice does), or kept around for offline review? Recommendation: keep them in `~/.paliad-poc/turns/{date}/` with a 30‑day janitor — m said "monitoring use", and raw response artefacts are great for post‑hoc analysis.
|
||||
3. **Q‑PoC‑3:** Should `/admin/paliadin` be reachable from the sidebar, or hidden behind a direct URL? Recommendation: sidebar entry (`/admin/paliadin`) since m is the only user and the only audience for the dashboard.
|
||||
4. **Q‑PoC‑4:** classifier_tag — let Claude self‑tag in the trailer block, or post‑process server‑side from the prompt text? Recommendation: Claude self‑tags (cheap and richer); we add a server‑side fallback if Claude's tag is missing.
|
||||
5. **Q‑PoC‑5:** Expansion gate threshold — §0.5.7 suggests "≥3 turns/working‑day, ≥50 % tool‑use rate, 4 weeks." Tighten? Loosen? Pure feel.
|
||||
|
||||
**Production‑v1‑deferred (only relevant if §0.5.7 expansion gate fires):**
|
||||
|
||||
- Q‑A (Anthropic key) — moot for PoC; Claude Code handles it.
|
||||
- Q‑B (first‑use disclosure) — moot; m‑only.
|
||||
- Q‑C (default model) — moot; Claude Code defaults.
|
||||
- Q‑D (sanity‑check with 2 PAs before locking scope) — *becomes* the expansion‑gate question. Don't ask the PAs about Paliadin until the PoC has earned the conversation.
|
||||
- Q‑E (surface confirmation) — kept; PoC ships the same `/paliadin` page so the question is already answered.
|
||||
- Q‑F (mascot) — Phase 2 still.
|
||||
- Q‑G (starter prompts) — relevant for the PoC empty state; recommendation unchanged.
|
||||
- Q‑H (`branding.Name` in prompt) — relevant for PoC; recommendation: yes, but the firm‑agnostic prompt can read "Paliad" instead of `branding.Name` since m's PoC is on his laptop and the firm‑name distinction adds no value for a single user.
|
||||
- Q‑I (rate limit) — moot for PoC.
|
||||
- Q‑J (youpc case‑law tool) — interesting at PoC since m himself does case‑law research; promoted to **Q‑PoC‑6**: include `lookup_youpc_case` as one of the system‑prompt SQL recipes from day one? Cross‑schema SELECT into `data.judgments` is technically trivial, and m is exactly the user who'd benefit. Recommendation: yes, include it.
|
||||
- Q‑K (audit retention) — PoC stores everything forever (one user, no compliance pressure).
|
||||
- Q‑L (default language) — moot; m's locale is set, Claude reads it.
|
||||
|
||||
---
|
||||
|
||||
## §9 What this design does NOT cover (deliberately)
|
||||
|
||||
- **The implementation.** This is a design pass; coder shift writes the code. No commits beyond this doc on the inventor branch.
|
||||
- **Mascot visual design.** Phase 2; deserves its own design pass (and probably a designer's eye, not an inventor's).
|
||||
- **HL Patents Style guide ingestion.** Out of v1; Phase 2 RAG candidate.
|
||||
- **Voice input / TTS output.** Phase 2.
|
||||
- **Multi‑user collaboration (e.g. share a paliadin chat).** Out of scope; users have their own visibility, and joint chat is a chat‑feature shape (parked).
|
||||
- **Offline mode.** Paliadin is online‑only by definition (it calls Anthropic). The PWA service worker should NOT cache `/paliadin` responses.
|
||||
- **The renaming question.** "Paliadin" is m's name. Locked.
|
||||
|
||||
---
|
||||
|
||||
## §10 Recommended implementer
|
||||
|
||||
Same recommendation as t‑145: **noether, or a fresh coder Sonnet that has noether's substrate context.** NOT cronus per the standing memory directive on paliad.
|
||||
|
||||
Why:
|
||||
|
||||
- Substrate touchpoints are the same set the chat design covered: `visibilityPredicate`, `auth.UserIDFromContext`, sidebar entry pattern, migration tracker discipline, Dashboard/Agenda/Project/Deadline service interfaces. noether built half of these; the other half noether mapped during the chat design pass.
|
||||
- Anthropic Go client is novel in paliad but is small and well‑specified by §6.2 + the `claude-api` skill.
|
||||
- Front‑end SSE consumer + chip parser is a one‑page TS file.
|
||||
|
||||
---
|
||||
|
||||
## §11 End of design — STOP
|
||||
|
||||
This is the inventor deliverable. Per the role brief: **STOP after design. Do not begin implementation. Do not load `/mai-coder`.** Wait for m's explicit go/no‑go on the questions in §8.5 before any coder shift starts.
|
||||
|
||||
The completion signal sent to head will use the literal phrase **"DESIGN READY FOR REVIEW"** so the head's gate fires.
|
||||
943
docs/design-paliadin-inline-2026-05-08.md
Normal file
943
docs/design-paliadin-inline-2026-05-08.md
Normal file
@@ -0,0 +1,943 @@
|
||||
# Inline Paliadin chat modal + agent-suggested-with-approval write path
|
||||
|
||||
**Inventor:** dirac · **Task:** t-paliad-161 · **Issue:** m/paliad#20
|
||||
**Date:** 2026-05-08 · **Branch:** `mai/dirac/inventor-inline-paliadin`
|
||||
**Status:** READY FOR REVIEW — awaiting m's go/no-go before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0 · TL;DR
|
||||
|
||||
Two intertwined upgrades, scoped together because the chat surface is where
|
||||
the write path is triggered and the write path is what makes the chat
|
||||
non-trivial:
|
||||
|
||||
1. **Inline modal**: a slide-out chat widget reachable from every
|
||||
authenticated paliad page, replacing the standalone `/paliadin` route's
|
||||
primacy (the page survives as the dedicated full-screen surface). The
|
||||
widget is **context-aware** — it knows which route the user is on, the
|
||||
primary entity in view, and any selected text — and uses that to
|
||||
pre-populate page-specific starter prompts.
|
||||
|
||||
2. **Agent-suggested write path**: Paliadin gains a *suggestion verb* that
|
||||
drafts a deadline / appointment / note / project edit straight into the
|
||||
existing `pending_create` lifecycle from t-paliad-160. The user reviews
|
||||
via the same eye-pill 👀 surface (`/inbox`, list/agenda views) and
|
||||
approves or rejects. Approved-from-suggestion rows pick up a sparkle ✨
|
||||
provenance glyph that lives **next to** 👀, not in place of it.
|
||||
|
||||
**Hard call**: the inline modal should **keep the existing tmux-relay
|
||||
backend** for v1. Cutover to the Anthropic Messages API is a separate
|
||||
substantial piece of work (auth, prompt-caching, tool framework, budget
|
||||
management); coupling it to the inline-modal ship would extend the design
|
||||
window past where m needs the modal to land. The design *recommends* the
|
||||
API cutover as a prerequisite for opening Paliadin beyond owner-only — but
|
||||
the inline modal at owner-only scope works fine on the existing relay.
|
||||
|
||||
**Key locked positions** (all reversible by m before coder shift):
|
||||
|
||||
| # | Decision | Position |
|
||||
|---|---|---|
|
||||
| 1 | Modal trigger | Floating button bottom-right + `Cmd/Ctrl-K` shortcut |
|
||||
| 2 | Surface shape | Right slide-out drawer, 420px desktop, full-screen on mobile |
|
||||
| 3 | Visibility | Every authenticated page **except** `/paliadin`, `/login`, `/onboarding` |
|
||||
| 4 | Gate | Same `PaliadinOwnerEmail` gate as today (no scope expansion in this task) |
|
||||
| 5 | Backend transport | Tmux relay (existing). Anthropic-API cutover deferred. |
|
||||
| 6 | Multi-turn coherence | Tmux session reuse already handles it; no client-side history hydrate beyond what's there |
|
||||
| 7 | Context payload | `route_name` + `primary_entity_type` + `primary_entity_id` + `user_selection_text` (optional) + page metadata |
|
||||
| 8 | Starter-prompt library | Per-route `paliadinStarters` registry, ships with 8 routes + a generic fallback |
|
||||
| 9 | Agent-suggested attribution | New columns on `paliad.approval_requests` (`requester_kind`, `agent_turn_id`); **not** on entity rows |
|
||||
| 10 | Visual language | ✨ glyph alongside 👀 on pending rows; persistent ✨ on approved-from-agent rows in audit log |
|
||||
| 11 | Persona separation | Single Paliadin SKILL.md unchanged. No pre-design for split personas. |
|
||||
| 12 | Concurrency | One in-flight turn per user enforced server-side (existing `turnMu`); request-side cancel via context |
|
||||
|
||||
---
|
||||
|
||||
## 1 · Premises verified live
|
||||
|
||||
Read the live system before designing on top — every claim below was
|
||||
checked against the running paliad.de + DB on 2026-05-08, not against
|
||||
CLAUDE.md or memory.
|
||||
|
||||
- **paliad.de**: live; root 200, `/paliadin` 302 (login redirect for
|
||||
anon). Production runs `RemotePaliadinService` against mRiver (CLAUDE.md
|
||||
flags `tmux + claude` as missing in the Dokploy container — confirmed
|
||||
the prod path actually goes through `paliadin-shim` over SSH).
|
||||
- **Migration tracker**: `paliad.paliad_schema_migrations.version=69`. Next
|
||||
free migration is **070**.
|
||||
- **`paliad.approval_requests`** existing columns: `id, project_id,
|
||||
entity_type, entity_id, lifecycle_event, pre_image, payload,
|
||||
requested_by, requested_at, required_role, status, decided_by,
|
||||
decided_at, decision_kind, decision_note, created_at, updated_at`. **No
|
||||
`agent_*` columns yet** — migration 070 adds them.
|
||||
- **`paliad.paliadin_turns`**: already has a `page_origin TEXT` column
|
||||
populated from `req.PageOrigin` on every turn. Today the frontend only
|
||||
ever sets `window.location.pathname` on the standalone page; the inline
|
||||
widget will widen this from a single string into a structured payload.
|
||||
- **`paliad.deadlines` + `paliad.appointments`**: already carry
|
||||
`approval_status text NOT NULL DEFAULT 'approved'` + `pending_request_id
|
||||
uuid` from migration 054. The 👀 eye-pill renders on pending rows in
|
||||
`events.ts:521` and `agenda.ts:289` via `.approval-pill--icon`.
|
||||
- **Sidebar** (`frontend/src/components/Sidebar.tsx:123`): already has a
|
||||
`/paliadin` entry hidden by default, revealed by `client/sidebar.ts`
|
||||
after `/api/me` confirms the caller is the Paliadin owner. The same
|
||||
reveal hook drives the inline modal's visibility.
|
||||
- **`PaliadinOwnerEmail`** (`internal/services/paliadin.go:51`):
|
||||
`matthias.siebels@hoganlovells.com`. Hard-coded gate. **No scope
|
||||
expansion in this task.**
|
||||
- **youpc.org reference files** all readable at
|
||||
`/home/m/dev/web/youpc.org/`: `frontend/templates/ai/sidebar-widget.html`,
|
||||
`frontend/js/utils/ai-chat-client.js`, `frontend/js/components/ai/sidebar.js`,
|
||||
`youpc-go/internal/services/youpc_ai_relay.go`, `scripts/youpc-ai-shim`.
|
||||
Klaus's brief in #20 maps to these directly.
|
||||
|
||||
**One CLAUDE.md correction**: the project's `CLAUDE.md` currently calls
|
||||
`ANTHROPIC_API_KEY` "reserved-but-unused for the eventual production-v1
|
||||
Paliadin". That language stays correct — this design *recommends but does
|
||||
not commit* the API cutover. No CLAUDE.md edit in the implementation PR.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Why the inline modal matters
|
||||
|
||||
m's framing (#20 §1) is "Paliadin should be reachable from anywhere". The
|
||||
real differentiation argument is sharper: the *value of the assistant
|
||||
collapses to "open a chat tab" if you can't get to it without leaving the
|
||||
page you're already working on.* For a patent-practice tool, the most
|
||||
common questions are page-anchored:
|
||||
|
||||
- On `/projects/<id>` → "Was steht für diese Akte diese Woche an?"
|
||||
- On `/deadlines/<id>` → "Erkläre mir die Klageerwiderungsfrist nach UPC RoP 23.1."
|
||||
- On `/agenda` with selection → "Schreibe einen Nachtrag zu diesem
|
||||
Termin: …"
|
||||
|
||||
The standalone `/paliadin` page solves none of these because asking the
|
||||
question requires the user to (a) leave the page, (b) re-explain context
|
||||
the page already had, (c) navigate back. The inline modal solves (a) by
|
||||
construction; (b) is solved by the **context payload** (§4); (c) is moot.
|
||||
|
||||
The widget is therefore the **default surface** going forward; the
|
||||
`/paliadin` standalone page survives as the dedicated full-screen mode
|
||||
(useful for long sessions where the slide-out is too narrow). Both speak
|
||||
the same backend.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Modal — shape, trigger, injection
|
||||
|
||||
### 3.1 Visual shape (recommendation)
|
||||
|
||||
**Right-edge slide-out drawer** — same pattern as youpc.org's
|
||||
`ai-sidebar-widget.html` because it solves the right problems:
|
||||
|
||||
- Doesn't crowd the page content (drawer slides in *over* a translucent
|
||||
scrim, page underneath stays visible at ~70% opacity so the user can
|
||||
reference what they were looking at).
|
||||
- Mobile-responsive for free: at `<640px` the drawer goes full-screen and
|
||||
the floating button hides while open.
|
||||
- Doesn't fight with paliad's existing left sidebar (`Sidebar.tsx`) — the
|
||||
drawer claims the right edge, the sidebar keeps the left.
|
||||
|
||||
**Considered and rejected:**
|
||||
|
||||
- *Always-visible secondary sidebar* (left or right rail). Wastes ~280px
|
||||
of horizontal real-estate on every page; collides with the sidebar on
|
||||
mobile.
|
||||
- *Popover anchored to the floating button*. Too small for multi-turn
|
||||
conversations; mobile would need a separate full-screen mode anyway.
|
||||
- *Fullscreen takeover overlay*. Defeats the purpose — if it covers the
|
||||
page you can't reference what you were looking at.
|
||||
|
||||
### 3.2 Trigger
|
||||
|
||||
Two entry points:
|
||||
|
||||
1. **Floating action button** at bottom-right (`position: fixed; bottom:
|
||||
20px; right: 20px;`). Lime accent (`var(--color-accent)`), ✨ glyph.
|
||||
Same auth-reveal hook as the sidebar `/paliadin` link — `display:none`
|
||||
until `client/sidebar.ts` confirms `/api/me.email ===
|
||||
PaliadinOwnerEmail`.
|
||||
|
||||
2. **Keyboard shortcut**: `Cmd-K` (macOS) / `Ctrl-K` (other). Standard
|
||||
command-palette muscle memory. Doesn't collide with browser shortcuts.
|
||||
Paliad has no other Cmd-K binding today (verified via grep on
|
||||
`keydown` handlers).
|
||||
|
||||
The shortcut also dismisses the drawer when it's open. `Esc` dismisses
|
||||
unconditionally.
|
||||
|
||||
### 3.3 Drawer content
|
||||
|
||||
Layout (top to bottom):
|
||||
|
||||
```
|
||||
┌──────────────────────────────┬─┐
|
||||
│ ✨ Paliadin ↻ ↗ ✕│ │ Header: name, reset-session, open-fullscreen, close
|
||||
├──────────────────────────────┼─┤
|
||||
│ [Auf dieser Seite] │
|
||||
│ Akte: Acme v. Müller │ │ Context chip — collapsible, shows what Paliadin
|
||||
│ 19 Fristen · 4 Termine │ │ knows about the current page (read from payload)
|
||||
├──────────────────────────────┼─┤
|
||||
│ [empty-state starter prompts] │
|
||||
│ • "Was steht hier an?" │
|
||||
│ • "Erkläre die offene…" │
|
||||
│ • "Lege eine Frist an" │
|
||||
├──────────────────────────────┼─┤
|
||||
│ <messages> │ │ Scrollable, user-right / paliadin-left
|
||||
│ > User bubble │
|
||||
│ < Paliadin bubble + ✨ chip │ │ ✨ chip = "I drafted this — it's awaiting your approval"
|
||||
├──────────────────────────────┼─┤
|
||||
│ [textarea + send + abort] │
|
||||
└──────────────────────────────┴─┘
|
||||
```
|
||||
|
||||
The `↗` button is the escape hatch to the standalone `/paliadin` for
|
||||
users who want a full-screen session with full message history visible.
|
||||
|
||||
### 3.4 Injection mechanism
|
||||
|
||||
**One file edits the universe**: `frontend/src/components/PaliadinWidget.tsx`
|
||||
emits an inline `<div id="paliadin-widget" style="display:none">…</div>`
|
||||
that page-template files include alongside `<PWAHead />` and `<Sidebar />`.
|
||||
|
||||
The mechanical edit pass: every authenticated TSX page (~30 files) gets a
|
||||
`<PaliadinWidget />` near `</body>`. This mirrors the existing
|
||||
`<PWAHead />` mechanical pass from t-paliad-042 and is the cleanest way to
|
||||
guarantee the widget reaches every page without HTMX or runtime injection.
|
||||
|
||||
**Alternative considered**: server-side template fragment injected by Go's
|
||||
HTML response writer (cleaner: no per-page edit). Rejected because paliad
|
||||
uses bun-built static HTML files, not templated server responses — there's
|
||||
no place to inject server-side. The mechanical pass is fine; the
|
||||
boilerplate it adds is one component.
|
||||
|
||||
**Visibility predicate** (in `client/paliadin-widget.ts`):
|
||||
|
||||
- **Hide** on `/paliadin` (the standalone page IS Paliadin, the widget
|
||||
would be redundant).
|
||||
- **Hide** on `/login`, `/onboarding` (no auth context).
|
||||
- **Hide** until `/api/me` resolves to `email === PaliadinOwnerEmail`.
|
||||
Same fail-closed pattern as the sidebar link.
|
||||
- **Show** on every other authenticated page.
|
||||
|
||||
### 3.5 What about the BottomNav (mobile)?
|
||||
|
||||
`BottomNav.tsx` has 5 slots (Dashboard / Projects / Add / Agenda / Menu)
|
||||
— full. Adding a Paliadin slot would require evicting one. **Don't.**
|
||||
The floating button is fine on mobile (it sits in the bottom-right corner
|
||||
*above* the bottom nav, with `z-index` arbitration). At full-screen-drawer
|
||||
size on mobile, the floating button hides while the drawer is open.
|
||||
|
||||
---
|
||||
|
||||
## 4 · Context payload — what flows from frontend to backend
|
||||
|
||||
### 4.1 Schema
|
||||
|
||||
The current `TurnRequest.PageOrigin` is a single string (the URL path).
|
||||
The inline modal needs more. Define a structured payload:
|
||||
|
||||
```ts
|
||||
interface PaliadinContext {
|
||||
// Stable route key — independent of URL params. e.g. "projects.detail"
|
||||
// not "/projects/61e3.../tab=team". The frontend computes this from
|
||||
// `window.location.pathname` via a route-table lookup.
|
||||
route_name: string;
|
||||
|
||||
// Path including query string (cosmetic; for audit + display only).
|
||||
page_origin: string;
|
||||
|
||||
// The "primary entity" of the current page, if any. Examples:
|
||||
// /projects/<id> → ("project", "<id>")
|
||||
// /deadlines/<id> → ("deadline", "<id>")
|
||||
// /appointments/<id> → ("appointment", "<id>")
|
||||
// /events?type=deadline → null
|
||||
// /tools/fristenrechner → null
|
||||
primary_entity_type?: "project" | "deadline" | "appointment";
|
||||
primary_entity_id?: string; // uuid
|
||||
|
||||
// User's text selection at the moment they opened the widget (or sent
|
||||
// the turn). Capped at 1000 chars. Empty string = no selection.
|
||||
// Source: window.getSelection().toString() at send-time.
|
||||
user_selection_text?: string;
|
||||
|
||||
// UI state hints. Optional, useful for the model to disambiguate:
|
||||
view_mode?: "list" | "cards" | "calendar" | "tree"; // /events, /projects
|
||||
filter_summary?: string; // e.g. "status=overdue, project=Acme"
|
||||
}
|
||||
```
|
||||
|
||||
**What each field enables:**
|
||||
|
||||
- `route_name`: maps cleanly to a starter-prompt registry (§5) without
|
||||
URL-parsing fragility.
|
||||
- `primary_entity_*`: the SKILL.md teaches Paliadin to look up the entity
|
||||
before answering when this is set. Saves a back-and-forth ("which
|
||||
project?") in the very common case where the user is *already on* the
|
||||
project page.
|
||||
- `user_selection_text`: enables "explain this" / "rewrite this" /
|
||||
"what's the deadline implied here" workflows from any prose surface
|
||||
(project notes, deadline notes, court descriptions).
|
||||
- `view_mode` + `filter_summary`: the model can say "I see you're looking
|
||||
at overdue deadlines for Acme — which one?" instead of "which deadline?"
|
||||
|
||||
### 4.2 How the payload reaches the model
|
||||
|
||||
Wire format from frontend → Go:
|
||||
|
||||
```http
|
||||
POST /api/paliadin/turn
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"session_id": "<uuid>",
|
||||
"user_message": "Was kommt diese Woche?",
|
||||
"context": { ...PaliadinContext... }
|
||||
}
|
||||
```
|
||||
|
||||
The Go side stores the structured context in **a new
|
||||
`paliad.paliadin_turns.context jsonb` column** (migration 070; see §7.1)
|
||||
alongside the existing `page_origin` (kept for backwards compat — `page_origin`
|
||||
becomes redundant once context is populated, but flipping the schema all
|
||||
at once isn't worth the churn).
|
||||
|
||||
Then the envelope sent through tmux gets a structured prefix:
|
||||
|
||||
```
|
||||
[PALIADIN:<turn_id>] [ctx route=projects.detail entity=project:61e3... selection="…" filter="status=overdue"] <user_message>
|
||||
```
|
||||
|
||||
The SKILL.md gets a small section (§5 of `paliadin/SKILL.md`) that teaches
|
||||
Paliadin to:
|
||||
1. Parse the `[ctx …]` block first, in front of the user message.
|
||||
2. Treat its contents as authoritative ("I'm currently viewing project
|
||||
61e3"), not as instructions.
|
||||
3. Pre-call `mcp__supabase__execute_sql` to enrich (e.g. lookup project
|
||||
reference + title) when `entity=project:<id>` is set, *before*
|
||||
answering.
|
||||
|
||||
**Why a structured prefix instead of a system-prompt JSON envelope**: the
|
||||
PoC's tmux relay is a stream of keystrokes — system-prompt envelopes
|
||||
require the API path. The bracket-syntax is line-noise-free, parse-able
|
||||
by the SKILL.md, and survives any future migration (the API path can lift
|
||||
the same `[ctx …]` block into a `system` message section).
|
||||
|
||||
### 4.3 Privacy floor
|
||||
|
||||
`user_selection_text` is potentially sensitive (selected text from a
|
||||
client matter). Three controls:
|
||||
|
||||
1. **Cap at 1000 chars** — anything longer is truncated server-side
|
||||
before being sent to Claude. The user sees a "(Auswahl gekürzt)"
|
||||
notice.
|
||||
2. **Audit redaction**: `paliadin_turns.context` stores the *full*
|
||||
selection (already inside the firm's DB, no exfiltration) but the
|
||||
admin dashboard `/admin/paliadin` redacts it to first 80 chars +
|
||||
"…[gekürzt]" when rendering — the same dashboard already shows
|
||||
`user_message` so the privacy posture is consistent.
|
||||
3. **Opt-out**: the widget's settings panel (a `⚙` corner in the header,
|
||||
v1 minimal) gets a single toggle "Aktuelle Auswahl mitsenden" default
|
||||
*on*. Off ⇒ context payload sets `user_selection_text=""` regardless
|
||||
of `getSelection()`.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Page-prompt-prefill — Klaus's wow-pattern, paliad-specific
|
||||
|
||||
### 5.1 The registry
|
||||
|
||||
A static client-side registry maps `route_name` → starter prompts. Lives
|
||||
in `frontend/src/client/paliadin-starters.ts`.
|
||||
|
||||
```ts
|
||||
type Starter = { label_de: string; label_en: string; prompt_de: string; prompt_en: string };
|
||||
|
||||
export const paliadinStarters: Record<string, Starter[]> = {
|
||||
"dashboard": [
|
||||
{ label_de: "Heute", label_en: "Today",
|
||||
prompt_de: "Was steht heute an?", prompt_en: "What's on my plate today?" },
|
||||
{ label_de: "Diese Woche", label_en: "This week",
|
||||
prompt_de: "Welche Fristen sind diese Woche?", prompt_en: "Which deadlines are this week?" },
|
||||
{ label_de: "Nächste Schritte", label_en: "Next steps",
|
||||
prompt_de: "Was sollte ich als nächstes erledigen?", prompt_en: "What should I tackle next?" },
|
||||
],
|
||||
"projects.detail": [
|
||||
{ label_de: "Status der Akte", label_en: "Project status",
|
||||
prompt_de: "Was ist der aktuelle Status dieser Akte?", prompt_en: "What's the status of this project?" },
|
||||
{ label_de: "Diese Woche", label_en: "This week",
|
||||
prompt_de: "Was steht für diese Akte diese Woche an?", prompt_en: "What's on for this project this week?" },
|
||||
{ label_de: "Frist anlegen", label_en: "Add a deadline",
|
||||
prompt_de: "Lege eine Frist für diese Akte an: ", prompt_en: "Add a deadline for this project: " },
|
||||
],
|
||||
"deadlines.detail": [
|
||||
{ label_de: "Erkläre die Frist", label_en: "Explain this deadline",
|
||||
prompt_de: "Erkläre mir die Frist auf dieser Seite.", prompt_en: "Explain this deadline." },
|
||||
{ label_de: "Rechtsgrundlage", label_en: "Legal basis",
|
||||
prompt_de: "Welche Norm ist hier einschlägig?", prompt_en: "What's the relevant rule?" },
|
||||
],
|
||||
"agenda": [ /* … */ ],
|
||||
"events": [ /* … */ ],
|
||||
"inbox": [ /* … */ ],
|
||||
"tools.fristenrechner": [ /* … */ ],
|
||||
"glossary": [ /* … */ ],
|
||||
// Generic fallback for unmapped routes.
|
||||
"_default": [
|
||||
{ label_de: "Was kann ich für dich tun?", label_en: "What can I help with?",
|
||||
prompt_de: "", prompt_en: "" },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The widget's empty state renders the matching starter list. Click → the
|
||||
prompt populates the textarea (or sends immediately if `prompt_de` is
|
||||
empty — letting the user type their own). Picking up "Lege eine Frist an: "
|
||||
seeds the input *partially* so the user finishes the sentence — a
|
||||
deliberate friction-reducer for the common "draft and approve" workflow.
|
||||
|
||||
### 5.2 Why per-route registry, not LLM-generated suggestions?
|
||||
|
||||
Considered: dynamically ask Paliadin to suggest 3 starters based on
|
||||
context. Rejected because:
|
||||
|
||||
1. **Latency**: every drawer-open would burn a full turn before the user
|
||||
even types. The PoC's tmux turn is ~2-5 seconds cold; that's an
|
||||
unusable empty state.
|
||||
2. **Determinism**: m's audience (PA team) needs predictable affordances.
|
||||
"What does this thing know how to do?" answered the same way each
|
||||
visit beats "what does this thing know how to do *today*?"
|
||||
3. **Translatability**: hand-crafted bilingual starters live next to the
|
||||
rest of the i18n. LLM-generated would be one language at a time.
|
||||
|
||||
The registry is small (~10 routes × 3 starters × 2 langs = ~60 strings)
|
||||
and lives next to `i18n.ts` patterns m's team already understands.
|
||||
|
||||
---
|
||||
|
||||
## 6 · Backend transport — tmux relay vs Anthropic API
|
||||
|
||||
### 6.1 Recommendation: keep tmux relay for v1 of the inline modal
|
||||
|
||||
Two reasons:
|
||||
|
||||
1. **Scope discipline**: the inline modal's user-visible payoff is
|
||||
independent of which backend serves it. Cutover to the API is a 4-6
|
||||
commit piece of substantial work (auth headers, prompt-cache
|
||||
management, tool-definition framework, streaming format conversion,
|
||||
budget controls, audit reshape, plus the existing tmux path needs to
|
||||
remain as fallback during rollout). Bundling it with the inline modal
|
||||
doubles the design's blast radius for no inline-modal-side benefit.
|
||||
2. **Owner-only scope**: paliad's user base today is `PaliadinOwnerEmail =
|
||||
m`. One user. The tmux relay's serialised one-turn-at-a-time, ~2-5s
|
||||
cold start, ~1-3s warm response holds up fine for one user clicking
|
||||
through the day.
|
||||
|
||||
### 6.2 What the API cutover *would* fix (recommend as Phase 2)
|
||||
|
||||
When scope expands beyond owner-only — even just to "m + 2 PA colleagues
|
||||
for piloting" — the tmux relay starts to bend:
|
||||
|
||||
- **Concurrency**: serialised turn lock means PA-A waits while PA-B
|
||||
thinks. Per-user tmux sessions help but mRiver still has finite
|
||||
resources.
|
||||
- **Latency**: ~2s cold tmux start is ok for one user; bad for "I just
|
||||
opened the widget, ask a quick question, close" rhythm at scale.
|
||||
- **Cost vs subscription**: m's Claude Code subscription covers his
|
||||
personal turns. Multi-user would either need m's account to absorb the
|
||||
load (dubious) or the firm's enterprise key (the actual prod path).
|
||||
- **Streaming**: tmux streaming today is the youpc.org-style "tail the
|
||||
response file as it grows" stopgap. Real token streaming (TTFB <1s)
|
||||
needs the API.
|
||||
|
||||
The API cutover should therefore be **a prerequisite for opening Paliadin
|
||||
beyond owner-only**. The inline modal's design assumes API-cutover-ready
|
||||
boundaries (the relay interface in §6.4) so when m flips the switch, the
|
||||
inline-modal frontend doesn't change.
|
||||
|
||||
### 6.3 Why not cutover now anyway?
|
||||
|
||||
It's tempting because:
|
||||
|
||||
- The CLAUDE.md note about `ANTHROPIC_API_KEY` reserved-but-unused has
|
||||
been there since 2026-04-16 and would benefit from being un-deferred.
|
||||
- The inline modal is the natural moment to revisit infrastructure.
|
||||
- Klaus's youpc.org has built a relay-interface abstraction
|
||||
(`youpcAIRelay` interface in `youpc_ai_relay.go`) that paliad could
|
||||
borrow for the swap point.
|
||||
|
||||
**Counter-arguments that win:**
|
||||
|
||||
- Today's tmux relay shipped only 2-3 days ago (`paliadin_remote.go`
|
||||
reference t-paliad-151). It's not a legacy substrate to escape — it's
|
||||
fresh code that hasn't earned a rewrite yet.
|
||||
- The compliance question for the API path (HLC-key vs personal-key,
|
||||
audit retention requirements, prompt-logging policy) hasn't been
|
||||
resolved with HLC IT. m flagged this as the **biggest open question** in
|
||||
the t-paliad-146 design and it's still open.
|
||||
- Inline modal can ship entirely on the existing relay; if the API
|
||||
cutover comes later, the modal doesn't have to re-ship.
|
||||
|
||||
**Therefore**: design a small interface seam (§6.4) so v1 doesn't paint us
|
||||
into a tmux-only corner, but don't pay the cutover cost in this PR.
|
||||
|
||||
### 6.4 Relay-interface seam (small, optional, recommended)
|
||||
|
||||
Mirror youpc.org's pattern (`youpc_ai_relay.go`) but smaller — paliad
|
||||
has one role, no streaming variant yet:
|
||||
|
||||
```go
|
||||
// internal/services/paliadin_relay.go (new)
|
||||
type PaliadinRelay interface {
|
||||
RunTurn(ctx context.Context, session string, turnID uuid.UUID,
|
||||
envelope string) ([]byte, error)
|
||||
Reset(ctx context.Context, session string) error
|
||||
HealthGate(ctx context.Context, session string) error
|
||||
}
|
||||
```
|
||||
|
||||
`LocalPaliadinService` and `RemotePaliadinService` keep their current
|
||||
shapes; the audit-row writes (`paliadinDB`) stay shared. `RunTurn` becomes
|
||||
a thin wrapper that builds the envelope (with the new `[ctx …]` block from
|
||||
§4.2) and delegates to the relay. A future `httpAPIRelay` slots in beside
|
||||
the SSH one without touching the audit/turn-row code.
|
||||
|
||||
**Don't extract the interface unless the inline modal's PR organically
|
||||
needs it.** If the modal can ship without restructuring the existing
|
||||
relay, the abstraction-cost is negative.
|
||||
|
||||
---
|
||||
|
||||
## 7 · Agent-suggested write path — schema + flow
|
||||
|
||||
### 7.1 Schema decision: extend `approval_requests`, not entity rows
|
||||
|
||||
The brief listed three candidate locations:
|
||||
|
||||
| Option | Where the marker lives | Verdict |
|
||||
|---|---|---|
|
||||
| A | `boolean agent_suggested` on `paliad.deadlines` / `paliad.appointments` | **Reject**: pollutes domain tables; survives past approval (the entity is no longer "agent-suggested" once it's been live for six months); doesn't carry which agent / which turn |
|
||||
| B | `text suggested_by_agent` on entity rows (multi-agent provenance) | Same problems as A; "agent name" never used because we have one agent |
|
||||
| C | New columns on `paliad.approval_requests` linking back to the suggesting turn | **Recommended** |
|
||||
|
||||
The `approval_request` row IS the audit-chain entry; the entity row is
|
||||
just current state. Provenance information belongs on the audit-chain row
|
||||
where it can persist forever without polluting the entity schema.
|
||||
|
||||
**Migration 070 (proposed):**
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.approval_requests
|
||||
-- 'user' = direct user create; 'agent' = drafted by Paliadin from a chat turn.
|
||||
ADD COLUMN requester_kind text NOT NULL DEFAULT 'user'
|
||||
CHECK (requester_kind IN ('user', 'agent')),
|
||||
-- When requester_kind='agent', the chat turn the suggestion came from.
|
||||
-- NULL otherwise. ON DELETE SET NULL — the audit record survives even
|
||||
-- if the turn row is purged (paliadin_turns has no retention policy
|
||||
-- today, but design for it).
|
||||
ADD COLUMN agent_turn_id uuid
|
||||
REFERENCES paliad.paliadin_turns(turn_id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT approval_requests_agent_xor
|
||||
CHECK (
|
||||
(requester_kind = 'agent' AND agent_turn_id IS NOT NULL)
|
||||
OR (requester_kind = 'user' AND agent_turn_id IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_requests_agent_turn_idx
|
||||
ON paliad.approval_requests (agent_turn_id)
|
||||
WHERE agent_turn_id IS NOT NULL;
|
||||
|
||||
-- paliadin_turns also gets the structured context column.
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
ADD COLUMN context jsonb;
|
||||
```
|
||||
|
||||
`requested_by` continues to be the user uuid — even for agent suggestions
|
||||
the user is the *initiator* (Paliadin acts on their behalf, never
|
||||
autonomously). `requester_kind` distinguishes "the user typed Speichern"
|
||||
from "the user typed `/lege eine Frist an: …` to Paliadin and Paliadin
|
||||
drafted it; the user has not yet approved".
|
||||
|
||||
### 7.2 The flow
|
||||
|
||||
1. **User asks Paliadin**: "Lege eine Frist für diese Akte an: 16.05.
|
||||
Klageerwiderung Acme".
|
||||
|
||||
2. **Paliadin's SKILL.md gets a new section**: "Agent-suggested writes"
|
||||
that teaches it to call a new MCP tool `paliad__suggest_deadline` (and
|
||||
siblings for appointment / project_note / project_attach). The tool's
|
||||
server-side handler:
|
||||
|
||||
- Validates the user has visibility on the project (existing
|
||||
`can_see_project`).
|
||||
- Calls `DeadlineService.Create` *with the new
|
||||
`IsAgentSuggestion=true` flag* and `agent_turn_id=<current turn>`.
|
||||
- Inside the create-tx, after the entity insert, the existing approval
|
||||
hookup runs: `ApprovalService.SubmitCreate(...)`. **Critical
|
||||
change**: when `IsAgentSuggestion` is set, the submit unconditionally
|
||||
creates an approval request *even if no policy applies* — the agent
|
||||
path is approval-gated by construction, not by partner-unit policy.
|
||||
|
||||
3. **Eye-pill 👀 + sparkle ✨** render on the resulting row in `/inbox`,
|
||||
`/deadlines`, `/agenda`. Click → standard approve/reject UI. Approve
|
||||
flips status to `approved`, sets `decision_kind='peer'` (or
|
||||
admin_override if global_admin), the entity becomes live.
|
||||
|
||||
4. **Audit chain on the project's Verlauf**:
|
||||
|
||||
- `deadline_approval_requested` event with
|
||||
`metadata.requester_kind='agent'` + `metadata.agent_turn_id=<uuid>`.
|
||||
Verlauf renderer picks this up and labels the event "Paliadin hat
|
||||
eine Frist vorgeschlagen ✨".
|
||||
- `deadline_approval_approved` with the user as `decided_by` + the
|
||||
existing `decision_kind` ladder. Verlauf renders "Anna hat
|
||||
Paliadin's Vorschlag genehmigt ✨".
|
||||
|
||||
### 7.3 Why agent-suggested unconditionally goes through approval
|
||||
|
||||
Two reasons:
|
||||
|
||||
1. **Trust gradient**: even if a partner has direct create authority on
|
||||
their own projects (no policy = no approval needed today), an agent
|
||||
suggesting on their behalf is qualitatively different. Visible review
|
||||
keeps the user in the loop.
|
||||
2. **Single audit shape**: today the partner-unit policy decides which
|
||||
creates need approval; bypassing that for agent suggestions creates a
|
||||
second code path. Forcing agent suggestions into the approval pipeline
|
||||
means there's exactly one "agent created an entity" audit shape (the
|
||||
approval_request row).
|
||||
|
||||
A user who finds the per-suggestion review tedious can request `/genehmige
|
||||
einfach alles was Paliadin vorschlägt` — but that's a Phase 2 setting
|
||||
("auto-approve agent suggestions on projects where I'm lead"), explicitly
|
||||
out-of-scope for v1 (and m says so in #20: "Multi-turn agent loops …
|
||||
Every creation gets the user's eye.").
|
||||
|
||||
### 7.4 What entities can Paliadin suggest in v1?
|
||||
|
||||
The brief mentions "deadlines, appointments, notes, project-tree edits".
|
||||
Recommend ordering by reversibility + audit complexity:
|
||||
|
||||
| Entity | v1? | Why |
|
||||
|---|---|---|
|
||||
| Deadline create | **Yes** | Highest-value (Klaus would rate this top), well-supported by existing `pending_create` lifecycle |
|
||||
| Appointment create | **Yes** | Same lifecycle substrate; symmetric tool |
|
||||
| Project note (`project_events.note`) | **Yes** | Read-only audit event, no approval gate today — but for agent-authored notes route through approval anyway (consistency) |
|
||||
| Project-tree edit (move, rename) | **No, defer** | Approval lifecycle for project moves doesn't exist; designing it is its own task. |
|
||||
| Deadline / appointment **edit** | **No, defer** | Edits today only need approval when date-fields change (t-paliad-138 §Q4). Agent edits would need their own design pass for "what changes does the user see in the diff?" |
|
||||
| Deadline **complete** | **No, defer** | Same reason — complete already has approval lifecycle, but the agent path is qualitatively different (a deadline being marked done is high-stakes; design it after a v1 lands and we see how often agent-creates need editing) |
|
||||
|
||||
**v1 = create only**. Edits/completes are a Phase 2 expansion.
|
||||
|
||||
---
|
||||
|
||||
## 8 · Visual language — ✨ alongside 👀, not in place of
|
||||
|
||||
### 8.1 Design
|
||||
|
||||
`.approval-pill--agent` is a new modifier that sits **next to** the
|
||||
existing `.approval-pill--icon` (the 👀 glyph), not replacing it.
|
||||
|
||||
| Row state | Pill rendering |
|
||||
|---|---|
|
||||
| `approval_status='pending'` AND `requester_kind='user'` | 👀 |
|
||||
| `approval_status='pending'` AND `requester_kind='agent'` | 👀 ✨ |
|
||||
| `approval_status='approved'` AND `requester_kind='user'` | (no pill) |
|
||||
| `approval_status='approved'` AND `requester_kind='agent'` | ✨ (subtle, in the row's *secondary* badge slot — not a pill) |
|
||||
|
||||
The 👀 + ✨ pairing communicates: "this is awaiting approval *and* came
|
||||
from Paliadin". Hover (`title` attr) on ✨ reads:
|
||||
"Paliadin hat das vorgeschlagen — angeklickt klärt".
|
||||
|
||||
**Why both glyphs, not a fused single glyph?** The two questions ("is
|
||||
this awaiting approval?" / "did a human or Paliadin originate this?") are
|
||||
orthogonal — a future autopilot mode might let some agent suggestions
|
||||
auto-approve, in which case 👀 disappears but ✨ stays. Keeping them
|
||||
separate keeps the visual taxonomy decomposable.
|
||||
|
||||
### 8.2 Where ✨ renders
|
||||
|
||||
Three surfaces:
|
||||
|
||||
1. **Eye-pill row** (`/inbox`, `/deadlines`, `/agenda`, project detail,
|
||||
/events): 👀 ✨ side-by-side when applicable. Same `.approval-pill`
|
||||
shape, separate elements.
|
||||
2. **Audit log** (`/admin/audit-log` + project Verlauf): the row's
|
||||
"approved by" line gets a trailing ✨ when the underlying request had
|
||||
`requester_kind='agent'`. Reads "Anna ✨ Schmidt" → tooltip "Über
|
||||
Paliadin vorgeschlagen, von Anna genehmigt".
|
||||
3. **Approval request inbox card**: the requester's name in the inbox
|
||||
card gets a subtle "✨ Paliadin (für Anna)" badge instead of just
|
||||
"Anna" when `requester_kind='agent'`.
|
||||
|
||||
### 8.3 The "+p" annotation question
|
||||
|
||||
m's #20 said: "we say USER + p or with a star or something". The "+p"
|
||||
text annotation reads in audit logs but doesn't scan in a pill row (✨ is
|
||||
recognisable; "+p" is not without learning). **Recommend**: ✨ as the
|
||||
universal glyph. Reserve a textual fallback for compliance-export
|
||||
contexts where emojis don't render — there the audit string becomes
|
||||
"Anna [agent: Paliadin]" rather than "Anna ✨".
|
||||
|
||||
---
|
||||
|
||||
## 9 · Persona separation
|
||||
|
||||
m's brief asked whether to lean on klaus's "scope-bouncer in SKILL.md"
|
||||
pattern (Hugo refuses legal questions, points at Lexie; Lexie refuses
|
||||
"how do I subscribe?", points at Hugo) for paliad — i.e. pre-design
|
||||
multi-persona infrastructure.
|
||||
|
||||
**Recommendation: don't.** Paliad has one Paliadin (Patentpraxis assistant
|
||||
at HLC's Patent team). The youpc.org split exists because *youpc.org has
|
||||
fundamentally different audiences* — public visitors (Hugo handles "how
|
||||
does this site work?") and premium-beta lawyers (Lexie does case-law
|
||||
research). Their refusal scopes are different because their users are
|
||||
different.
|
||||
|
||||
Paliad's audience is one cohesive group: HLC PA team. They want one
|
||||
assistant that does "everything PA-relevant" — Aktenmanagement, Fristen,
|
||||
Begriffe, Gerichte, UPC-Recht. There's no audience pair that requires
|
||||
distinct refusal scopes.
|
||||
|
||||
**If Phase 2 wants to add a case-law research persona** (e.g. cross-link
|
||||
to youpc.org's Lexie) — *that's a separate skill alongside Paliadin*, not
|
||||
a persona-split inside Paliadin. The infrastructure for that already
|
||||
exists in Claude Code's skill router (multiple skills, each its own
|
||||
description/persona).
|
||||
|
||||
**No SKILL.md changes for persona separation in this design**. The skill
|
||||
gets §4.2's `[ctx …]` parser added, plus §7.2's `paliad__suggest_*` tool
|
||||
guidance, but the persona stays "der Paliad-Patentpraxis-Assistent".
|
||||
|
||||
---
|
||||
|
||||
## 10 · Phasing & implementation surface
|
||||
|
||||
### 10.1 Suggested phasing (single PR is feasible; split optional)
|
||||
|
||||
**Slice A — schema + relay seam** (~1 commit)
|
||||
- Migration 070: `approval_requests.requester_kind` +
|
||||
`agent_turn_id` + xor-check + index; `paliadin_turns.context jsonb`.
|
||||
- Optional `PaliadinRelay` interface extraction (skip if it makes the PR
|
||||
bigger without removing duplication).
|
||||
|
||||
**Slice B — context payload + SKILL.md update** (~1 commit)
|
||||
- Wire structured `PaliadinContext` from frontend → Go → tmux envelope.
|
||||
- SKILL.md `[ctx …]` parsing + behaviour.
|
||||
- `client/paliadin-context.ts` route-table + entity extraction (one file).
|
||||
- `/api/paliadin/turn` accepts the new body shape (backwards-compatible:
|
||||
old `page_origin` still honoured if `context` is absent).
|
||||
|
||||
**Slice C — inline widget** (~1 commit, biggest)
|
||||
- `frontend/src/components/PaliadinWidget.tsx`.
|
||||
- `client/paliadin-widget.ts` (drawer state, sending, history, hide-on-route).
|
||||
- `client/paliadin-starters.ts` registry (8 routes + default).
|
||||
- Mechanical pass: every authenticated TSX adds `<PaliadinWidget />`.
|
||||
- CSS: `.paliadin-widget`, `.paliadin-drawer`, `.paliadin-trigger`,
|
||||
`.paliadin-context-chip`, ~150 lines of `global.css`.
|
||||
- ~30 i18n keys.
|
||||
|
||||
**Slice D — agent-suggested write path** (~1 commit)
|
||||
- `paliad__suggest_deadline` + `paliad__suggest_appointment` MCP tools
|
||||
(or HTTP tool, depending on how the MCP scope already wires —
|
||||
`internal/handlers/paliadin_tools.go` if new file warranted).
|
||||
- `DeadlineService.Create` / `AppointmentService.Create` accept a
|
||||
`IsAgentSuggestion bool` + `AgentTurnID *uuid.UUID` plumbed into
|
||||
`ApprovalService.SubmitCreate` (which gets a sibling
|
||||
`SubmitAgentCreate` that always creates a request even without policy).
|
||||
- SKILL.md adds the §7.2 "Agent-suggested writes" instruction block.
|
||||
|
||||
**Slice E — visual language** (~1 commit)
|
||||
- `.approval-pill--agent` CSS.
|
||||
- `events.ts`, `agenda.ts`, `inbox.ts` render ✨ when
|
||||
`requester_kind='agent'`.
|
||||
- Audit-log + Verlauf renderer extends to surface ✨ on approved-from-agent
|
||||
events.
|
||||
- ~10 i18n keys for the badges + tooltips.
|
||||
|
||||
**Recommended PR shape**: single PR with five commits in this order. Slice
|
||||
A's migration is independent (can deploy without the rest); Slice D needs
|
||||
B + C; Slice E builds on D. If sliced into multiple PRs, A and B-C can
|
||||
ship independently of D-E (modal works as read-only chat without the
|
||||
write path; that's already an upgrade).
|
||||
|
||||
### 10.2 Files of note for the implementer
|
||||
|
||||
**New files:**
|
||||
- `internal/db/migrations/070_paliadin_inline.{up,down}.sql`
|
||||
- `internal/handlers/paliadin_tools.go` (suggest verbs)
|
||||
- `internal/services/paliadin_relay.go` (optional interface)
|
||||
- `frontend/src/components/PaliadinWidget.tsx`
|
||||
- `frontend/src/client/paliadin-widget.ts`
|
||||
- `frontend/src/client/paliadin-starters.ts`
|
||||
- `frontend/src/client/paliadin-context.ts`
|
||||
|
||||
**Edits:**
|
||||
- `internal/services/paliadin.go` (TurnRequest gains structured Context;
|
||||
insertTurnRow stores it)
|
||||
- `internal/services/approval_service.go` (SubmitCreate accepts
|
||||
agent-flag; SubmitAgentCreate variant)
|
||||
- `internal/services/deadline_service.go`,
|
||||
`internal/services/appointment_service.go` (Create accepts
|
||||
IsAgentSuggestion + AgentTurnID; threads to ApprovalService)
|
||||
- `internal/handlers/paliadin.go` (turnRequest body schema)
|
||||
- `frontend/src/client/events.ts`, `agenda.ts`, `inbox.ts` (✨ render)
|
||||
- `frontend/src/styles/global.css` (drawer + ✨ pill CSS)
|
||||
- `frontend/src/client/i18n.ts` (~40 new keys × 2 langs)
|
||||
- `frontend/src/components/Sidebar.tsx` — no edit (the existing sidebar
|
||||
link logic already gates on owner; no new entries)
|
||||
- ~30 page TSX files: mechanical `<PaliadinWidget />` add (~1 line each)
|
||||
- `~/.claude/skills/paliadin/SKILL.md` (via `scripts/install-paliadin-skill`):
|
||||
add §4.2 ctx-parser block + §7.2 suggest-tools block
|
||||
|
||||
**Total estimated surface**: comparable to t-paliad-146 (the original
|
||||
Paliadin design — ~3500-4500 LoC) plus the agent-suggest write path
|
||||
(~1000 LoC). Single PR is feasible if the implementer is pattern-fluent;
|
||||
split is fine.
|
||||
|
||||
---
|
||||
|
||||
## 11 · Open questions for m
|
||||
|
||||
These are the calls m has to make before any coder shift starts.
|
||||
|
||||
### Q1 — Scope gate: still owner-only?
|
||||
The inline modal's design assumes `PaliadinOwnerEmail` stays as the only
|
||||
gate (m only). When does scope expand?
|
||||
- (a) **Stays owner-only for v1** of inline modal — recommended; matches
|
||||
brief. ← **inventor's pick**
|
||||
- (b) Extend to a beta-features whitelist (firm-wide email domain + flag).
|
||||
- (c) Expand to all of `hoganlovells.com` immediately. Requires API
|
||||
cutover (Phase 2 prerequisite).
|
||||
|
||||
### Q2 — Backend: tmux relay or Anthropic API for the inline modal?
|
||||
- (a) **Keep tmux relay** for v1 — recommended; ships fastest. ← **inventor's pick**
|
||||
- (b) Cutover to Anthropic API now — slower ship; better long-term.
|
||||
- (c) Both: ship tmux v1, design the API path as a parallel deferred PR.
|
||||
|
||||
### Q3 — Agent-suggested entities in v1: where to draw the line?
|
||||
- (a) **Create-only**: deadline, appointment, note. Defer edits/completes/project-tree. ← **inventor's pick**
|
||||
- (b) Create + edit (deadline + appointment).
|
||||
- (c) Create + edit + complete + project-tree.
|
||||
|
||||
### Q4 — Visual language for agent provenance?
|
||||
- (a) **✨ glyph alongside 👀** — recommended; orthogonal to lifecycle. ← **inventor's pick**
|
||||
- (b) "+p" text annotation in audit lines only; no glyph in pills.
|
||||
- (c) Replace 👀 with ✨ for agent-pending rows (single glyph, more compact).
|
||||
|
||||
### Q5 — Selection text in context payload — default on or off?
|
||||
- (a) **Default on**, opt-out via widget settings — recommended. ← **inventor's pick**
|
||||
- (b) Default off, opt-in via widget settings.
|
||||
- (c) Always on, no toggle.
|
||||
|
||||
### Q6 — Widget visibility scope: everywhere except `/paliadin`, or finer?
|
||||
- (a) **Everywhere except `/paliadin`, `/login`, `/onboarding`** —
|
||||
recommended; lowest cognitive load. ← **inventor's pick**
|
||||
- (b) Only on data-bearing pages (dashboard, projects, deadlines, agenda,
|
||||
events, inbox); hide on tool pages (fristenrechner etc.).
|
||||
- (c) User-configurable per page.
|
||||
|
||||
### Q7 — Modal vs dialog: drawer + scrim, or non-modal floating panel?
|
||||
- (a) **Modal slide-out drawer with scrim** (focus-traps) — recommended. ← **inventor's pick**
|
||||
- (b) Non-modal floating panel (page stays interactive while widget is open).
|
||||
|
||||
### Q8 — Keyboard shortcut for opening: Cmd-K?
|
||||
- (a) **Cmd-K / Ctrl-K** — recommended. ← **inventor's pick**
|
||||
- (b) Different shortcut (m to specify).
|
||||
- (c) No shortcut, button-only.
|
||||
|
||||
### Q9 — Context payload truncation cap (selection text)?
|
||||
- (a) **1000 chars** — recommended; balances usefulness vs prompt-bloat. ← **inventor's pick**
|
||||
- (b) Higher cap (5000 chars).
|
||||
- (c) Lower cap (300 chars).
|
||||
|
||||
### Q10 — Persona separation pre-design?
|
||||
- (a) **Single Paliadin, no scope-bouncer pattern** — recommended; YAGNI. ← **inventor's pick**
|
||||
- (b) Add scope-bouncer pattern now (Paliadin refuses non-paliad questions, points at... where?).
|
||||
- (c) Pre-design split with a second skill (Phase 2 case-law researcher).
|
||||
|
||||
### Q11 — Auto-approve some agent suggestions?
|
||||
- (a) **No, every agent suggestion needs the user's eye** — recommended; matches m's #20 verbatim. ← **inventor's pick**
|
||||
- (b) Auto-approve agent suggestions on projects where the user is lead.
|
||||
- (c) Auto-approve when the suggestion was a direct response to "Lege … an" (user opted in by phrasing).
|
||||
|
||||
### Q12 — Recommended implementer?
|
||||
Same substrate as t-paliad-146 + t-paliad-160 + t-paliad-138 (paliadin,
|
||||
approval pipeline, eye-pill UI). Pattern-fluent Sonnet work.
|
||||
- (a) **Any pattern-fluent Sonnet coder** — recommended. ← **inventor's pick**
|
||||
- (b) The same coder who shipped t-paliad-160 (deepest context on the
|
||||
approval pipeline).
|
||||
- (c) Two coders: one on Slices A-C (modal + context), one on Slices D-E
|
||||
(agent-suggest + visual language).
|
||||
|
||||
---
|
||||
|
||||
## 12 · Out of scope (for now) — preserved
|
||||
|
||||
Per m's brief:
|
||||
|
||||
- Direct Paliadin write permission (no RLS bypass, no agent service-role
|
||||
identity). The approval gate stays the only path agents take into prod
|
||||
data.
|
||||
- Multi-turn agent loops — no chained writes without per-step user
|
||||
approval.
|
||||
- Production-v1 Anthropic API cutover for the existing standalone
|
||||
`/paliadin` route (recommended in §6 as a *prerequisite* for opening
|
||||
beyond owner-only, but not committed in this task).
|
||||
- Edits / completes / project-tree as agent-suggestible entities (§7.4
|
||||
defers to Phase 2).
|
||||
- Persona separation infrastructure (§9 defers indefinitely).
|
||||
|
||||
---
|
||||
|
||||
## 13 · Trade-offs flagged
|
||||
|
||||
| Trade-off | What we accept | Mitigation |
|
||||
|---|---|---|
|
||||
| Tmux-relay v1 caps concurrency at one turn per user | Owner-only v1 makes this fine | Spec the relay-interface seam (§6.4) so API cutover is non-disruptive |
|
||||
| Mechanical `<PaliadinWidget />` pass touches ~30 files | Same pattern as t-paliad-042 PWAHead, low risk | One commit per slice keeps blame surface tight |
|
||||
| Agent suggestions unconditionally route through approval | Some users may find it tedious | Phase 2 auto-approve setting (m wants Q11 = no, so this isn't urgent) |
|
||||
| Two glyphs (👀 + ✨) might confuse first-time approvers | Slight onboarding cost | Tooltip on hover; admin/onboarding doc one-liner |
|
||||
| Selection-text in context payload risks accidental info leakage | Low (data already in DB) | Cap + redaction in admin dashboard (§4.3) |
|
||||
| Per-route starter registry needs maintenance as routes evolve | Yes; cost is real | Default fallback ensures no route is silent; route renames are caught by build (registry imports route names as a const map) |
|
||||
|
||||
---
|
||||
|
||||
## 14 · Implementation hygiene
|
||||
|
||||
- **No bare CSS tokens.** New `.paliadin-widget*` + `.approval-pill--agent`
|
||||
CSS uses existing `--color-*` / `--accent-*` / `--bg-soft` tokens. The
|
||||
reminder from t-paliad-150 (third occurrence of bare-token leaks) holds.
|
||||
- **No RAISE EXCEPTION in migration 070** — Maria's build constraint.
|
||||
- **No `2>&1` on diagnostic** — global rule.
|
||||
- **i18n must compile** — every new label gets a key in `client/i18n.ts`
|
||||
+ DE/EN values; `bun run build` regenerates `i18n-keys.ts`.
|
||||
- **Build + vet + test gate** — `go build ./...` + `go vet ./...` +
|
||||
`go test ./...` + `cd frontend && bun run build` all clean before push.
|
||||
- **Don't self-merge** — push branch, comment on Gitea #20, await m's
|
||||
merge gate.
|
||||
- **Don't close issue #20** — m closes issues. Set `done` label on
|
||||
approval.
|
||||
|
||||
---
|
||||
|
||||
## 15 · End-of-shift checklist (this design)
|
||||
|
||||
- [x] Read m/paliad#20 + Klaus's reply (msg #1563 / comment).
|
||||
- [x] Read existing `paliadin.go` + `paliadin_remote.go` + `approval_service.go` + `paliadin-shim` + `install-paliadin-skill` + `~/.claude/skills/paliadin/SKILL.md`.
|
||||
- [x] Read youpc.org reference: `sidebar-widget.html` + `sidebar.js` + `ai-chat-client.js` + `youpc_ai_relay.go`.
|
||||
- [x] Verify live state: paliad.de up, migration tracker at 69, schema columns matched expectations, eye-pill 👀 already wired.
|
||||
- [x] Take a position on every decision in the brief (see §0 table; §11 for the open questions).
|
||||
- [x] No hour estimates anywhere in the doc.
|
||||
- [x] Recommend implementer + phasing.
|
||||
- [ ] Commit this doc on `mai/dirac/inventor-inline-paliadin`.
|
||||
- [ ] Push branch.
|
||||
- [ ] Comment on Gitea #20 with summary + doc link.
|
||||
- [ ] File mBrian synthesis node under `topic-paliadin` (or equivalent).
|
||||
- [ ] `mai report completed "DESIGN READY FOR REVIEW: …"` and **stop**. Do not auto-flip to coder.
|
||||
|
||||
---
|
||||
|
||||
*Inventor parked after this commit. The head will surface to m for the
|
||||
go/no-go gate before any coder shift begins. Skipping that gate has
|
||||
burned commits before (m/mAi#142); the gate is non-negotiable.*
|
||||
677
docs/design-paliadin-tailscale-ssh-2026-05-07.md
Normal file
677
docs/design-paliadin-tailscale-ssh-2026-05-07.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# Paliadin: route prod via Tailscale SSH to mRiver
|
||||
|
||||
**Issue:** m/paliad#12 — t-paliad-151
|
||||
**Date:** 2026-05-07
|
||||
**Author:** noether (inventor)
|
||||
**Supersedes nothing.** Extends `docs/design-paliadin-2026-05-07.md` (the Phase 0 PoC) with a third deployment path between "laptop-only PoC" and "Anthropic API direct".
|
||||
**Related:** t-paliad-146 (PoC ship), t-paliad-150 (`friendlyErrorMessage` pattern).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make Paliadin reachable from `paliad.de` (Dokploy on mLake) without losing m's Claude Code subscription, by routing each turn over Tailscale + SSH from the paliad container to mRiver, where the existing long-lived `tmux` + `claude` PoC keeps running.
|
||||
|
||||
**Non-goals (v1):**
|
||||
|
||||
- Multi-host failover.
|
||||
- Encryption beyond SSH-over-tailnet (already E2E-encrypted by Tailscale's WireGuard layer).
|
||||
- Anthropic API fallback when mRiver is offline — show a friendly error instead.
|
||||
- Wake-on-LAN of mRiver.
|
||||
- Multi-tenant or multi-firm variants.
|
||||
|
||||
---
|
||||
|
||||
## 2. Live state — what was verified before designing
|
||||
|
||||
A design built on stale facts rots fast. These were probed on 2026-05-07, not assumed from CLAUDE.md or memory:
|
||||
|
||||
| Fact | How verified | Result |
|
||||
|---|---|---|
|
||||
| mRiver = `100.99.98.203`, has tmux + claude | this worker runs on mRiver; `tmux -V` → `tmux 3.6a`; `which claude` → `/home/m/.local/bin/claude` | confirmed |
|
||||
| mLake (`100.99.98.201`) has Tailscale running | `ssh m@mlake tailscale status` | confirmed; mRiver visible as `active; direct [2a02:4780:41:3fbc::1]:41641` |
|
||||
| paliad container Dockerfile is alpine:3.21 minimal, no SSH, no tailscaled | `Dockerfile` | confirmed (only `ca-certificates`) |
|
||||
| paliad compose runs default Docker bridge (no `network_mode`) | `docker-compose.yml` | confirmed |
|
||||
| mRiver has no `~/.ssh/authorized_keys` yet | `ls ~/.ssh/` | confirmed — file must be created in Phase A |
|
||||
| `/tmp/paliadin/` does not exist on mRiver yet | `ls /tmp/paliadin` | confirmed — created on first turn (paliadin.go:185 `os.MkdirAll`) |
|
||||
| `paliad-paliadin` tmux session is not currently running on mRiver | `tmux ls` | not present; the existing PoC creates it on demand |
|
||||
|
||||
**Implication for design:** the paliad container needs new infrastructure on three axes — network reachability of the tailnet, an SSH client + identity, and a service-layer code path that talks to a remote tmux instead of a local one. Each axis is its own sub-design below.
|
||||
|
||||
---
|
||||
|
||||
## 3. Locked decisions (m, 2026-05-07 22:35)
|
||||
|
||||
m made four design-shaping calls via the inventor's `AskUserQuestion` pass. They are recorded here verbatim because every downstream choice in §4–§6 follows from them.
|
||||
|
||||
| # | Question | m's choice |
|
||||
|---|---|---|
|
||||
| 1 | Container Tailscale shape | **`network_mode: host` on paliad** |
|
||||
| 2 | SSH-to-mRiver protocol granularity | **Server-side `paliadin-shim` (one RPC per turn)** |
|
||||
| 3 | Routing trigger | **Env var `PALIADIN_REMOTE_HOST` + interface split** |
|
||||
| 4 | SSH private key storage | **Dokploy secret env var `PALIADIN_SSH_PRIVATE_KEY`** |
|
||||
| 5 | SSH port to bypass Tailscale SSH | **Port 22022 via `ssh.socket` drop-in (Phase A finding, 23:30)** |
|
||||
|
||||
Decision (1) was *not* the inventor's recommendation — host mode has known interaction risk with traefik (§4.2). m is overriding the recommendation; this design accepts the call and codifies a Phase A test step that gates the rollout on traefik still working under host mode. If Phase A blows up, the fallback is to revisit (1) in a follow-up issue, not to silently swap to a sidecar.
|
||||
|
||||
Decision (5) emerged during Phase A: Tailscale SSH on mRiver was found to intercept `:22` from tailnet peers and bypass OpenSSH's `authorized_keys` entirely (banner says "Tailscale", auth method "none"). The `command=` shim restriction therefore never fires on the standard port. Adding port 22022 via a `systemd ssh.socket` drop-in routes paliad's connections to real OpenSSH where the restriction works. m's interactive `tailscale ssh m@mriver` on `:22` stays untouched. See §4.4 for the implementation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sub-design A — Container Tailscale shape
|
||||
|
||||
### 4.1 Shape: `network_mode: host`
|
||||
|
||||
paliad's container shares mLake's network namespace. `tailscale0` (mLake's tailnet interface) is directly visible from inside the container. Outbound `ssh m@100.99.98.203` reaches mRiver over the tailnet without any sidecar, userspace tailscaled, SOCKS proxy, or auth-key flow inside the container.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml diff
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
network_mode: host # NEW
|
||||
# remove: expose: ["8080"] # host mode means port is on the host directly
|
||||
environment:
|
||||
- PORT=8080
|
||||
...
|
||||
# NEW Paliadin remote-routing knobs
|
||||
- PALIADIN_REMOTE_HOST=${PALIADIN_REMOTE_HOST} # 100.99.98.203
|
||||
- PALIADIN_REMOTE_PORT=${PALIADIN_REMOTE_PORT} # 22022 (bypasses Tailscale SSH, see §4.5)
|
||||
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER} # m
|
||||
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
|
||||
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS} # one-line ssh-keyscan -p 22022 output
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 4.2 Trade-off accepted: traefik routing under host mode
|
||||
|
||||
paliad.de's TLS is provided by Dokploy's traefik on the `dokploy-network` overlay. With `network_mode: host`, paliad is no longer attached to that overlay. Two failure modes are possible:
|
||||
|
||||
- **(M1)** traefik can't discover the service via Docker DNS → 502 at the edge.
|
||||
- **(M2)** traefik routes via host loopback (`http://127.0.0.1:8080` or `host.docker.internal`) and works fine.
|
||||
|
||||
Recent Dokploy versions configure traefik with both `loadbalancer.server.url` and Docker labels; (M2) is the documented host-mode path. **Phase A explicitly tests this** (§7) before any code is written; if (M1) materialises, the design rolls back to the sidecar variant of decision 1 in a follow-up issue.
|
||||
|
||||
Other host-mode side-effects to flag in operations:
|
||||
|
||||
- paliad listens on host port 8080 directly. Any other compose service binding 8080 conflicts.
|
||||
- paliad's outbound DNS uses host resolver (no Docker-internal `web` etc.). Currently fine: paliad's only network deps are external (Supabase, SMTP, GitHub raw). No service on `dokploy-network` is referenced by name.
|
||||
- The container can reach **every** Tailscale node, not just mRiver. Mitigations live in §5 (key restriction) and §5.2 (`from=` clause on mRiver authorized_keys).
|
||||
|
||||
### 4.3 Dockerfile diff
|
||||
|
||||
```dockerfile
|
||||
# Final stage adds the SSH client only. Tailscale is provided by the host.
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates openssh-client # +openssh-client (~1MB)
|
||||
WORKDIR /app
|
||||
COPY --from=backend /paliad /app/paliad
|
||||
COPY --from=frontend /app/frontend/dist /app/dist
|
||||
EXPOSE 8080
|
||||
CMD ["/app/paliad"]
|
||||
```
|
||||
|
||||
Image-size delta: alpine `openssh-client` is ~1.1 MB compressed — negligible. No tailscaled, no entrypoint script, no extra processes inside the container.
|
||||
|
||||
### 4.4 What does NOT change
|
||||
|
||||
- No Tailscale auth-key inside paliad. The container inherits the host's tailnet binding, so there is no per-container Tailscale identity to rotate. mLake's existing Tailscale auth is the only one in scope.
|
||||
- No tailscaled process inside the container.
|
||||
- No new sidecar container.
|
||||
|
||||
### 4.5 Bypassing Tailscale SSH via port 22022 (Phase A discovery)
|
||||
|
||||
**Phase A revealed** that Tailscale SSH on mRiver intercepts `:22` from tailnet peers before OpenSSH sees the connection. The SSH banner reads `SSH-2.0-Tailscale`, the verbose log shows `Authenticated using "none"`, and the `authorized_keys command=` directive is therefore inert. mRiver's `tailscale status --json` confirms the `https://tailscale.com/cap/ssh` capability is enabled.
|
||||
|
||||
The fix: a separate listening port for the paliad route, where Tailscale SSH does not intercept and real OpenSSH handles auth.
|
||||
|
||||
mRiver uses systemd socket activation for sshd (`/usr/lib/systemd/system/ssh.socket` binds `:22`). Setting `Port 22022` in `sshd_config` is **ignored** under socket activation — listen ports come from the socket unit, not sshd's own config. The correct change is a drop-in:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/ssh.socket.d/paliad.conf
|
||||
[Socket]
|
||||
ListenStream=0.0.0.0:22022
|
||||
ListenStream=[::]:22022
|
||||
```
|
||||
|
||||
Followed by `systemctl daemon-reload && systemctl restart ssh.socket`. Both `:22` (still routed through Tailscale SSH for m's interactive use) and `:22022` (real OpenSSH) end up listening. The same sshd binary handles both — same host key, same `authorized_keys`, same sshd_config. The only difference is *which port* a peer dials.
|
||||
|
||||
A failed first attempt (2026-05-07 23:07) added the drop-in while a stale `Port 22022` directive in `sshd_config.d/99-paliad-test.conf` was still bound — the resulting `Address already in use` took `ssh.socket` down for ~30 s until reverted. Lesson: clean any prior `Port` directives out of `sshd_config.d/*.conf` before retrying the socket drop-in.
|
||||
|
||||
Phase A end-to-end test (2026-05-07 23:31) succeeded with port 22022:
|
||||
|
||||
- `ssh -p 22022 -i paliad-prod-key m@100.99.98.203 health` → `ok`
|
||||
- `run-turn <uuid> <base64-msg>` → 3.4 s round-trip including a Claude-Code response
|
||||
- `from="100.99.98.201"` correctly rejected a connection sourced from mRiver itself (`Permission denied (publickey,password)`)
|
||||
|
||||
---
|
||||
|
||||
## 5. Sub-design B — SSH identity, restricted shim, host-key pinning
|
||||
|
||||
### 5.1 Identity: dedicated ed25519 keypair `paliad-prod`
|
||||
|
||||
One keypair, generated once on mRiver during Phase A, used by every paliad-prod deploy:
|
||||
|
||||
```bash
|
||||
# On mRiver (Phase A bootstrap):
|
||||
ssh-keygen -t ed25519 -N "" -C "paliad-prod $(date +%Y-%m-%d)" -f /tmp/paliad-prod-key
|
||||
# Public key → mRiver authorized_keys (see 5.2)
|
||||
# Private key → Dokploy secret store as PALIADIN_SSH_PRIVATE_KEY
|
||||
shred -u /tmp/paliad-prod-key # only the encrypted/secret-stored copies survive
|
||||
```
|
||||
|
||||
Rotation: regenerate, push public key to mRiver authorized_keys, update Dokploy secret, redeploy. No code change needed — paliad's startup re-reads the env var on every boot.
|
||||
|
||||
The private key is delivered to the container as a multi-line env var. At process start, paliad writes it to a tmpfile so OpenSSH can use it:
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (sketch)
|
||||
func loadPaliadinSSHKey() (string, error) {
|
||||
blob := os.Getenv("PALIADIN_SSH_PRIVATE_KEY")
|
||||
if blob == "" { return "", nil } // remote mode disabled
|
||||
f, err := os.CreateTemp("", "paliadin-id_ed25519-")
|
||||
if err != nil { return "", err }
|
||||
if err := os.Chmod(f.Name(), 0o600); err != nil { return "", err }
|
||||
if _, err := f.WriteString(blob); err != nil { return "", err }
|
||||
if err := f.Close(); err != nil { return "", err }
|
||||
return f.Name(), nil // path passed to RemotePaliadinService
|
||||
}
|
||||
```
|
||||
|
||||
The tmpfile lives at `/tmp/paliadin-id_ed25519-<rand>` for the container's lifetime. On container restart, a fresh tmpfile is written. We never persist the key to a volume.
|
||||
|
||||
### 5.2 mRiver `authorized_keys` entry
|
||||
|
||||
```
|
||||
command="/home/m/.local/bin/paliadin-shim",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc,from="100.99.98.201" ssh-ed25519 AAAA...PUBKEY... paliad-prod
|
||||
```
|
||||
|
||||
Each restriction matters:
|
||||
|
||||
- `command=` — every `ssh m@mriver …` invocation runs the shim regardless of what the client asked for. The client's requested command is exposed as `$SSH_ORIGINAL_COMMAND` for the shim to dispatch on.
|
||||
- `no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc` — defence-in-depth: even if someone steals the key and bypasses the shim's argument validation, they can't get an interactive shell, can't tunnel ports, can't pivot via agent forwarding.
|
||||
- `from="100.99.98.201"` — only accept connections from mLake's tailnet IP. Defends against the "container has full tailnet visibility" host-mode side-effect from §4.2: if the key leaks off mLake, it can't be replayed from another tailnet host.
|
||||
|
||||
### 5.3 Host-key pinning
|
||||
|
||||
`StrictHostKeyChecking=accept-new` is too loose for a long-lived production identity (one-time MITM during first connect substitutes a different key forever). Instead:
|
||||
|
||||
- During Phase A, run `ssh-keyscan -p 22022 -t ed25519 100.99.98.203` on mLake.
|
||||
- Capture the single output line. The host-key portion is identical to the `:22` entry — same sshd, same keys — but the `[100.99.98.203]:22022` prefix matters because OpenSSH's `known_hosts` is `host:port`-keyed for non-22 ports.
|
||||
- Store as Dokploy secret `PALIADIN_KNOWN_HOSTS`.
|
||||
- At container startup, write to `/tmp/paliadin-known_hosts` chmod 644.
|
||||
- Pass to OpenSSH via `-o UserKnownHostsFile=/tmp/paliadin-known_hosts -o StrictHostKeyChecking=yes`.
|
||||
|
||||
If mRiver's host key ever rotates (rare; only on disk wipe / fresh OS), Phase A runs again and the secret is updated. SSH refuses to connect with a clear "host key changed" error, which surfaces as `mriver_unreachable` to the user — exactly the right blast-radius (loud failure, no silent connect to a substitute host).
|
||||
|
||||
### 5.4 The shim — `paliadin-shim`
|
||||
|
||||
A bash script on mRiver at `/home/m/.local/bin/paliadin-shim`. It is the **only** thing the paliad-prod key is allowed to invoke, and it dispatches on `$SSH_ORIGINAL_COMMAND`. Three RPCs:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# paliadin-shim — server-side RPC for paliad's remote-tmux turns.
|
||||
# Invoked via authorized_keys command= with $SSH_ORIGINAL_COMMAND set.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
readonly TMUX_SESSION="${PALIADIN_TMUX_SESSION:-paliad-paliadin}"
|
||||
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
|
||||
readonly TIMEOUT_S=60
|
||||
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
|
||||
mkdir -p "$RESPONSE_DIR"
|
||||
|
||||
# Parse $SSH_ORIGINAL_COMMAND. Format: "<verb> <arg1> <arg2> …"
|
||||
read -r -a argv <<< "${SSH_ORIGINAL_COMMAND:-}"
|
||||
verb="${argv[0]:-}"
|
||||
|
||||
ensure_pane() {
|
||||
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
|
||||
tmux new-session -d -s "$TMUX_SESSION"
|
||||
fi
|
||||
# Find or create the @paliadin-scope=chat window.
|
||||
local target=""
|
||||
while read -r idx; do
|
||||
scope=$(tmux show-window-option -t "$TMUX_SESSION:$idx" -v @paliadin-scope 2>/dev/null || true)
|
||||
if [[ "$scope" == "chat" ]]; then target="$TMUX_SESSION:$idx"; break; fi
|
||||
done < <(tmux list-windows -t "$TMUX_SESSION" -F '#{window_index}')
|
||||
if [[ -z "$target" ]]; then
|
||||
idx=$(tmux new-window -t "$TMUX_SESSION" -n claude-paliadin -P -F '#{window_index}' claude)
|
||||
target="$TMUX_SESSION:$idx"
|
||||
# Wait for claude to settle (60s bound; matches Go waitForPaneReady).
|
||||
for _ in $(seq 1 120); do
|
||||
pane=$(tmux capture-pane -t "$target" -p 2>/dev/null || true)
|
||||
if [[ "$pane" == *"❯"* || "$pane" == *"│"* ]]; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
tmux set-window-option -t "$target" @paliadin-scope chat
|
||||
tmux set-window-option -t "$target" @fix-name claude-paliadin
|
||||
# Bootstrap system prompt — reuses the Go service's prompt text.
|
||||
# The Go side sends this via the `bootstrap` RPC on first turn instead
|
||||
# of duplicating the prompt here. See §6.4.
|
||||
fi
|
||||
echo "$target"
|
||||
}
|
||||
|
||||
case "$verb" in
|
||||
health)
|
||||
# Liveness check — used by paliad to short-circuit when mRiver is offline.
|
||||
# Returns "ok" iff tmux + claude are reachable.
|
||||
tmux has-session -t "$TMUX_SESSION" 2>/dev/null \
|
||||
|| tmux new-session -d -s "$TMUX_SESSION"
|
||||
command -v claude >/dev/null && echo ok || { echo no-claude; exit 1; }
|
||||
;;
|
||||
|
||||
bootstrap)
|
||||
# First-turn-only: ensure pane exists and inject the system prompt.
|
||||
# $1 = base64-encoded prompt body (avoids quoting hell).
|
||||
target=$(ensure_pane)
|
||||
prompt=$(printf '%s' "${argv[1]:?missing prompt}" | base64 -d)
|
||||
tmux send-keys -t "$target" -l -- "$prompt"
|
||||
tmux send-keys -t "$target" Enter
|
||||
sleep 2 # give claude a moment to absorb
|
||||
echo ok
|
||||
;;
|
||||
|
||||
run-turn)
|
||||
# $1 = turn_id (UUID); $2 = base64-encoded user message.
|
||||
turn_id="${argv[1]:?missing turn_id}"
|
||||
[[ "$turn_id" =~ $TURN_ID_RE ]] || { echo >&2 "bad turn_id"; exit 2; }
|
||||
msg=$(printf '%s' "${argv[2]:?missing message}" | base64 -d)
|
||||
target=$(ensure_pane)
|
||||
out="$RESPONSE_DIR/$turn_id.txt"
|
||||
rm -f "$out"
|
||||
# Envelope matches what paliadin_prompt.go expects.
|
||||
tmux send-keys -t "$target" -l -- "[PALIADIN:$turn_id] $msg"
|
||||
tmux send-keys -t "$target" Enter
|
||||
# Poll for the response file. Same shape as Go pollForResponse.
|
||||
for _ in $(seq 1 $((TIMEOUT_S * 5))); do
|
||||
if [[ -s "$out" ]]; then
|
||||
sleep 0.05 # settle
|
||||
cat "$out"
|
||||
rm -f "$out"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
echo >&2 "paliadin: response timeout after ${TIMEOUT_S}s"
|
||||
exit 124
|
||||
;;
|
||||
|
||||
reset)
|
||||
# /clear the conversation; next turn starts fresh.
|
||||
target=$(ensure_pane)
|
||||
tmux send-keys -t "$target" -l -- "/clear"
|
||||
tmux send-keys -t "$target" Enter
|
||||
echo ok
|
||||
;;
|
||||
|
||||
*)
|
||||
echo >&2 "paliadin-shim: unknown verb '$verb'"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
Why a shim instead of raw tmux-over-SSH:
|
||||
|
||||
- One SSH round-trip per turn (~50 ms over tailnet) vs ~10–20 round-trips for the granular pattern.
|
||||
- Argument validation lives in one place (UUID regex on turn_id, base64 for messages, fixed verb list) — easier to audit than a regex over `$SSH_ORIGINAL_COMMAND` matching `tmux send-keys …`.
|
||||
- mRiver-side concerns (response polling, settle delays, pane-readiness) stay on mRiver, which is where the tmux state lives. The Go service stops caring about local file polling at all.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sub-design C — Service-layer integration, routing, reliability
|
||||
|
||||
### 6.1 Interface split
|
||||
|
||||
The current `*PaliadinService` becomes an interface with two implementations: `LocalPaliadinService` (the existing tmux code, renamed) and `RemotePaliadinService` (the new SSH code). Construction picks one at startup based on `PALIADIN_REMOTE_HOST`.
|
||||
|
||||
```go
|
||||
// internal/services/paliadin.go (after refactor)
|
||||
|
||||
type Paliadin interface {
|
||||
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
|
||||
ResetSession(ctx context.Context) error
|
||||
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
|
||||
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
|
||||
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// LocalPaliadinService wraps the current tmux PoC (laptop / dev path).
|
||||
type LocalPaliadinService struct { /* identical to today's PaliadinService */ }
|
||||
|
||||
// RemotePaliadinService talks to a paliadin-shim over SSH on mRiver.
|
||||
type RemotePaliadinService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
sshHost string // 100.99.98.203
|
||||
sshPort int // 22022 — bypasses Tailscale SSH on :22 (see §4.5)
|
||||
sshUser string // m
|
||||
sshKeyPath string // /tmp/paliadin-id_ed25519-<rand>
|
||||
knownHosts string // /tmp/paliadin-known_hosts
|
||||
turnMu sync.Mutex
|
||||
|
||||
// Health-check cache.
|
||||
healthMu sync.Mutex
|
||||
healthOK bool
|
||||
healthCheckedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
DB access (`ListRecentTurns`, `Stats`, `IsOwner`) is identical for both — they only read `paliad.paliadin_turns`. They live in a shared `paliadinDB` helper struct embedded in both implementations.
|
||||
|
||||
### 6.2 Wiring at startup
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (excerpt)
|
||||
var paliadin services.Paliadin
|
||||
remoteHost := os.Getenv("PALIADIN_REMOTE_HOST")
|
||||
switch {
|
||||
case remoteHost != "":
|
||||
keyPath, err := loadPaliadinSSHKey()
|
||||
if err != nil { log.Fatalf("paliadin: load ssh key: %v", err) }
|
||||
if keyPath == "" { log.Fatalf("paliadin: PALIADIN_REMOTE_HOST set but no PALIADIN_SSH_PRIVATE_KEY") }
|
||||
knownHosts, err := loadPaliadinKnownHosts()
|
||||
if err != nil { log.Fatalf("paliadin: load known_hosts: %v", err) }
|
||||
port, _ := strconv.Atoi(cmpOr(os.Getenv("PALIADIN_REMOTE_PORT"), "22022"))
|
||||
paliadin = services.NewRemotePaliadinService(db, userSvc, services.RemotePaliadinConfig{
|
||||
SSHHost: remoteHost,
|
||||
SSHPort: port,
|
||||
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
|
||||
SSHKeyPath: keyPath,
|
||||
KnownHostsPath: knownHosts,
|
||||
})
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d", "m", remoteHost, port)
|
||||
case localTmuxAvailable():
|
||||
paliadin = services.NewLocalPaliadinService(db, userSvc, "", "")
|
||||
log.Printf("paliadin: local tmux mode")
|
||||
default:
|
||||
paliadin = services.NewDisabledPaliadinService(db, userSvc)
|
||||
log.Printf("paliadin: disabled (no remote host, no local tmux)")
|
||||
}
|
||||
```
|
||||
|
||||
`NewDisabledPaliadinService` exists today implicitly via the `ErrTmuxUnavailable` path; making it explicit gives the constructor a clear name and the handler doesn't have to special-case `nil`.
|
||||
|
||||
### 6.3 SSH invocation pattern
|
||||
|
||||
`RemotePaliadinService` runs every RPC through the same helper:
|
||||
|
||||
```go
|
||||
func (s *RemotePaliadinService) callShim(ctx context.Context, args ...string) ([]byte, error) {
|
||||
sshArgs := []string{
|
||||
"-F", "/dev/null", // ignore /etc/ssh/ssh_config + ~/.ssh/config
|
||||
"-i", s.sshKeyPath,
|
||||
"-p", strconv.Itoa(s.sshPort), // 22022 — bypasses Tailscale SSH on :22
|
||||
"-o", "IdentitiesOnly=yes", // don't fall back to other keys
|
||||
"-o", "UserKnownHostsFile=" + s.knownHostsPath,
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=3",
|
||||
"-o", "ServerAliveInterval=10",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
s.sshUser + "@" + s.sshHost,
|
||||
"--",
|
||||
}
|
||||
sshArgs = append(sshArgs, args...)
|
||||
c, cancel := context.WithTimeout(ctx, 70*time.Second) // shim has its own 60s; +10s for SSH overhead
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(c, "ssh", sshArgs...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout; cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: ssh shim %v: %w (stderr: %s)", args, err, stderr.String())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
```
|
||||
|
||||
`RunTurn` becomes:
|
||||
|
||||
```go
|
||||
func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
if err := s.healthGate(ctx); err != nil {
|
||||
return nil, err // ErrMRiverUnreachable, picked up by handler
|
||||
}
|
||||
|
||||
turnID := uuid.New()
|
||||
started := time.Now().UTC()
|
||||
if err := s.insertTurnRow(ctx, …); err != nil { return nil, err }
|
||||
|
||||
// First-turn-only: bootstrap the system prompt on mRiver. Detected by
|
||||
// checking whether any prior turn for this user has succeeded.
|
||||
if err := s.ensureBootstrapped(ctx); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "bootstrap_failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := sanitiseForTmux(req.UserMessage)
|
||||
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
|
||||
body, err := s.callShim(ctx, "run-turn", turnID.String(), msgB64)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Same trailer-parse + audit-row writes as Local, factored into shared helper.
|
||||
return s.completeTurnFromBody(ctx, turnID, started, string(body))
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 System prompt bootstrap
|
||||
|
||||
The local PoC calls `paliadinSystemPrompt(s.responseDir)` once when it creates the pane. The remote path needs the same hook. Two options that don't require duplicating the German prompt body to mRiver:
|
||||
|
||||
- **Lazy bootstrap (chosen):** the first `RunTurn` after a paliad-prod restart sends the system prompt via `bootstrap` RPC, then runs the actual turn. Subsequent turns skip the bootstrap. State is per-process: `RemotePaliadinService.bootstrapped` boolean guarded by mutex.
|
||||
- Eager bootstrap at startup is rejected — it forces every container start to wait for mRiver to be online, which couples paliad's boot to mRiver's availability.
|
||||
|
||||
Lazy bootstrap means the very first turn after a paliad redeploy pays a ~3 s extra cost (claude pane spin-up + system prompt absorb). Acceptable for a single-user PoC.
|
||||
|
||||
### 6.5 Health-check gating (`mriver_unreachable`)
|
||||
|
||||
Every `RunTurn` first calls `healthGate(ctx)`:
|
||||
|
||||
- Cached for 10 s. If last check was <10 s ago and was OK, skip the probe.
|
||||
- Otherwise: `s.callShim(ctx, "health")` with a 3 s timeout. On success, set cache OK; on failure, return `ErrMRiverUnreachable`.
|
||||
|
||||
Why 10 s: short enough that "I just woke my laptop" propagates inside one user retry; long enough that a busy chat doesn't probe on every turn.
|
||||
|
||||
```go
|
||||
var ErrMRiverUnreachable = errors.New("paliadin: mriver unreachable")
|
||||
|
||||
func (s *RemotePaliadinService) healthGate(ctx context.Context) error {
|
||||
s.healthMu.Lock()
|
||||
defer s.healthMu.Unlock()
|
||||
if s.healthOK && time.Since(s.healthCheckedAt) < 10*time.Second {
|
||||
return nil
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := s.callShim(c, "health")
|
||||
s.healthCheckedAt = time.Now()
|
||||
if err != nil || strings.TrimSpace(string(out)) != "ok" {
|
||||
s.healthOK = false
|
||||
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
|
||||
}
|
||||
s.healthOK = true
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 Friendly error code (extends t-paliad-150)
|
||||
|
||||
`friendlyErrorMessage` already maps `tmux_unavailable` to a localised message. We add one new code:
|
||||
|
||||
- `mriver_unreachable` → DE: *"mRiver ist offline — Paliadin nicht erreichbar. Mach mRiver an, oder nutze Paliadin lokal mit `./paliad`."* / EN: *"mRiver is offline — Paliadin can't reach it. Wake mRiver, or run Paliadin locally with `./paliad`."*
|
||||
|
||||
Implementation: one new `case` in the SSE-error switch in `frontend/src/client/paliadin.ts`'s `friendlyErrorMessage`, plus matching i18n keys (`paliadin.error.mriver_unreachable.de` / `.en`). Server-side: `paliadin` HTTP handler maps `errors.Is(err, services.ErrMRiverUnreachable)` to `event: error\ndata: {"code":"mriver_unreachable","message":"..."}\n\n`.
|
||||
|
||||
### 6.7 Rate limit
|
||||
|
||||
A runaway loop on the paliad side could DOS the SSH connection. Cheapest cap: enforce one in-flight turn at a time via `turnMu` (already exists in the local PoC). On top of that, a rolling cap of N=20 turns/min in `RemotePaliadinService` rejects with `ErrRateLimited` (mapped to a friendly `paliadin.error.rate_limited`). PoC has one user (m); the cap is a paranoid safety, not a real throttle.
|
||||
|
||||
### 6.8 What about ControlMaster?
|
||||
|
||||
Decision-2's chosen path (server-side shim with one RPC per turn) makes ControlMaster optional. The shim collapses ~10 raw-tmux ops into a single SSH connect — that's already the latency win ControlMaster would buy.
|
||||
|
||||
Adding it on top would save ~30–50 ms per turn but adds:
|
||||
|
||||
- A persistent `~/.ssh/cm-*` socket inside the container.
|
||||
- Cleanup logic on shutdown.
|
||||
- A subtle interaction with the SSH BatchMode + ConnectTimeout settings.
|
||||
|
||||
Verdict: skip ControlMaster in v1. If turn latency over Tailscale is measured >300 ms in practice and hot enough to matter, add it in a follow-up; the call site is one helper.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phasing
|
||||
|
||||
### Phase A — manual proof-of-concept (no Dockerfile change yet)
|
||||
|
||||
Goal: validate the round-trip end-to-end on a deployed paliad, before touching the image.
|
||||
|
||||
**Phase A.0 (DONE 2026-05-07 23:31):** SSH+shim end-to-end on the tailnet.
|
||||
|
||||
1. ✅ **Generate keypair** on mRiver: `ssh-keygen -t ed25519 -N "" -C "paliad-prod" -f ~/.paliad-staging/paliad-prod-key`. Fingerprint `SHA256:5uV8v872F/IhJycjjq0crFue/emAYfw71N9bxTvkl9c`.
|
||||
2. ✅ **Commit shim** to `scripts/paliadin-shim` and **install** at `/home/m/.local/bin/paliadin-shim`, `chmod 755`.
|
||||
3. ✅ **Write authorized_keys** with public key + `command=`/`from="100.99.98.201"`/no-pty/no-port-forwarding/no-agent-forwarding/no-X11-forwarding/no-user-rc restrictions (§5.2).
|
||||
4. ✅ **Add port 22022 socket drop-in** at `/etc/systemd/system/ssh.socket.d/paliad.conf`, `systemctl daemon-reload && systemctl restart ssh.socket`. Both `:22` (Tailscale SSH for m) and `:22022` (real OpenSSH for paliad) listening (§4.5).
|
||||
5. ✅ **Capture mRiver:22022 host key**: `ssh-keyscan -p 22022 -t ed25519 100.99.98.203 > ~/.paliad-staging/known_hosts` from mLake. Fingerprint `SHA256:HPoUzy60Cb8yLERIBQcB2mHihNST3NaTODx5Ypd1XpA`.
|
||||
6. ✅ **Smoke-test from mLake** (without paliad container, just raw ssh from mLake's host shell):
|
||||
```
|
||||
ssh -F /dev/null -i /tmp/paliad-prod-key -o UserKnownHostsFile=/tmp/paliad-known_hosts \
|
||||
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
|
||||
-p 22022 m@100.99.98.203 health
|
||||
→ ok
|
||||
ssh … run-turn $(uuidgen) "$(printf 'Sag …' | base64 -w0)"
|
||||
→ "test ok" (3.4 s round-trip including a real Claude response)
|
||||
```
|
||||
7. ✅ **from= rejection verified**: the same key from mRiver itself (`100.99.98.203`) → `Permission denied (publickey,password)` as expected.
|
||||
|
||||
**Phase A.5 (PENDING m's hands):** validate `network_mode: host` + traefik routing on prod paliad.de.
|
||||
|
||||
- Branch the live `docker-compose.yml` on a temp branch.
|
||||
- Add `network_mode: host` to the `web` service; remove `expose: ["8080"]`.
|
||||
- Push to trigger a Dokploy redeploy.
|
||||
- `curl --connect-timeout 5 -sSI https://paliad.de/` — expect 200 (or login redirect), NOT 502.
|
||||
- If 502: revert the temp branch (`git revert HEAD && git push`); revisit decision 1 in a follow-up issue.
|
||||
- If 200: keep the host-mode change; ready for Phase B.
|
||||
|
||||
This is **m's call to execute** — it briefly touches prod paliad.de. Inventor/coder should not flip prod compose without explicit go-ahead. Rollback is one revert + redeploy.
|
||||
|
||||
**Phase A.6 (after A.5 passes):** smoke-test SSH from inside the paliad-prod container itself (the real container, not just the mLake host shell):
|
||||
```
|
||||
docker exec -it <paliad-container> sh
|
||||
apk add --no-cache openssh-client # one-shot, before Dockerfile change
|
||||
ssh -F /dev/null -i /tmp/paliad-prod-key -o UserKnownHostsFile=/tmp/paliad-known_hosts \
|
||||
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
|
||||
-p 22022 m@100.99.98.203 health
|
||||
# expected: "ok"
|
||||
```
|
||||
This proves the container's host-mode networking actually delivers a tailnet connect.
|
||||
|
||||
**Phase A.7:** wire env vars manually via Dokploy UI for one deploy; confirm `/paliadin` chat works against mRiver from paliad.de.
|
||||
|
||||
If A.5 fails: the design rolls back to a sidecar in a new issue (decision 1 follow-up). The SSH path (A.0) and traefik path (A.5) are independent — A.0 is already proven; only A.5+ is at risk.
|
||||
|
||||
### Phase B — bake into Dockerfile + Dokploy secrets
|
||||
|
||||
1. Dockerfile: add `openssh-client` to the final stage (§4.3).
|
||||
2. compose: add `network_mode: host` and the four new env vars (§4.1).
|
||||
3. Dokploy secrets: register `PALIADIN_REMOTE_HOST=100.99.98.203`, `PALIADIN_REMOTE_USER=m`, `PALIADIN_SSH_PRIVATE_KEY=...`, `PALIADIN_KNOWN_HOSTS=...`.
|
||||
4. Code: refactor `PaliadinService` to the interface split (§6.1–§6.2). New file `internal/services/paliadin_remote.go`. Tests: `paliadin_remote_test.go` mocks `callShim` to verify `RunTurn` audit-row writes, error mapping, and `healthGate` caching.
|
||||
5. Ship under one PR; tag t-paliad-151 done.
|
||||
|
||||
### Phase C — friendly errors + monitoring
|
||||
|
||||
1. `paliadin.error.mriver_unreachable` i18n keys + `friendlyErrorMessage` case (§6.6).
|
||||
2. `/admin/paliadin` shows last health-probe result + last successful turn timestamp.
|
||||
3. Optional: `mai-mesh` integration to surface mRiver-offline events to m on Telegram (out-of-band; not gating).
|
||||
|
||||
---
|
||||
|
||||
## 8. Security review summary
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Stolen private key → arbitrary SSH on mRiver | `command=` shim restriction + `from="100.99.98.201"` + ed25519 key + private key only in Dokploy secret store (encrypted at rest); paliad route uses port 22022 where real OpenSSH enforces all of the above |
|
||||
| Stolen private key → tailnet-wide SSH from non-mLake host | `from="100.99.98.201"` clause (verified: rejected from mRiver itself in Phase A.0) |
|
||||
| Tailscale SSH on `:22` bypasses `authorized_keys` | The paliad-prod key's `command=` restriction is not enforced on `:22`. Mitigation: paliad always dials `:22022`, which is real OpenSSH. m's interactive `tailscale ssh m@mriver` on `:22` continues to be governed by Tailscale ACLs, separate from paliad's identity. |
|
||||
| Container compromise → key extraction | Key written to tmpfile chmod 600, only root inside container can read; alpine container has no shell-on-error trampolines |
|
||||
| Host-key MITM during connect | Pinned `known_hosts`; `StrictHostKeyChecking=yes` |
|
||||
| Shim argument injection (e.g. via `run-turn $(rm -rf /)`) | Shim parses positional args from `$SSH_ORIGINAL_COMMAND` via `read -r -a`; never passes args to a subshell `eval`; turn_id validated by UUID regex; message body always base64-decoded into a single shell variable, never re-evaluated |
|
||||
| Runaway loop → SSH flood | Single-flight `turnMu` + 20/min rolling cap |
|
||||
| `network_mode: host` widens blast radius | The `command=` + `from=` restrictions on mRiver mean container compromise = "can run shim verbs against mRiver only", not "shell on mRiver" |
|
||||
| PaliadinOwnerEmail bypass | Unchanged from PoC: gate is in Go (`/paliadin` 404s for any other user). Even if mRiver SSH key leaks, attacker still needs paliad session as `m@hoganlovells.com`. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Out-of-scope clarifications (for review)
|
||||
|
||||
These were called out in the issue but the design intentionally does not solve them, to keep v1 tight. Each is acknowledged so review knows it wasn't an oversight:
|
||||
|
||||
- **Wake-on-LAN of mRiver:** out of scope. v1's UX when mRiver is asleep is the friendly error from §6.6. Future work: integrate with `mai-mesh` capability fallback.
|
||||
- **Multi-host failover:** out of scope. Only mRiver is targeted.
|
||||
- **Anthropic API fallback when mRiver offline:** out of scope per CLAUDE.md (`ANTHROPIC_API_KEY` reserved for production-v1, unused in PoC).
|
||||
- **ControlMaster:** v1 ships without; revisit if turn latency >300 ms in practice (§6.8).
|
||||
|
||||
---
|
||||
|
||||
## 10. File-level deliverables (for the coder shift)
|
||||
|
||||
When this design is approved and the coder shift starts, the work splits roughly into:
|
||||
|
||||
- `Dockerfile` — `+openssh-client`.
|
||||
- `docker-compose.yml` — `network_mode: host`, five new env entries (`PALIADIN_REMOTE_HOST`, `PALIADIN_REMOTE_PORT`, `PALIADIN_REMOTE_USER`, `PALIADIN_SSH_PRIVATE_KEY`, `PALIADIN_KNOWN_HOSTS`).
|
||||
- `internal/services/paliadin.go` — extract `Paliadin` interface; rename existing to `LocalPaliadinService`; pull DB-only methods (`ListRecentTurns`, `Stats`, `IsOwner`) into a shared embedded `paliadinDB` so both implementations get them for free.
|
||||
- `internal/services/paliadin_remote.go` — new file: `RemotePaliadinService`, `RemotePaliadinConfig` (with `SSHPort`), `callShim`, `healthGate`, `ensureBootstrapped`, `classifySSHError`, `ErrMRiverUnreachable`.
|
||||
- `internal/services/paliadin_remote_test.go` — unit tests with a mocked `callShim`.
|
||||
- `cmd/server/main.go` — env-var-based wiring (§6.2), `loadPaliadinSSHKey`, `loadPaliadinKnownHosts`, `PALIADIN_REMOTE_PORT` parse with default `22022`.
|
||||
- `frontend/src/client/paliadin.ts` — one `case` in `friendlyErrorMessage` for `mriver_unreachable`.
|
||||
- `frontend/src/i18n.ts` — two new keys (`paliadin.error.mriver_unreachable.de` / `.en`).
|
||||
- `scripts/paliadin-shim` — server-side script (§5.4); already shipped + installed on mRiver during Phase A.0, not part of any container. Repo location chosen so the security-relevant script is version-controlled.
|
||||
- `docs/project-status.md` — note Phase 0.5 (PoC) → Phase 0.6 (Tailscale-SSH prod route).
|
||||
- **mRiver host setup (one-time, already done in Phase A.0):** `/etc/systemd/system/ssh.socket.d/paliad.conf` (port 22022 listen drop-in); `~/.ssh/authorized_keys` (paliad-prod public key with restrictions); `/home/m/.local/bin/paliadin-shim` (executable). These are NOT in the repo because they live on m's laptop; `docs/project-status.md` should reference them.
|
||||
|
||||
No DB migrations needed — `paliad.paliadin_turns` schema already covers everything (`error_code` field already accepts free-form codes including `mriver_unreachable`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for review
|
||||
|
||||
- **Q (m), still open:** Phase A.5 (traefik+host-mode on prod paliad.de) is not yet executed. m drives this; rollback is one revert. Dokploy doc check before flipping is recommended but not blocking.
|
||||
- **Q (m), resolved 2026-05-07 23:50:** shim location → repo (`scripts/paliadin-shim`, committed in `0248411`). Version-controlled and auditable.
|
||||
- **Q (m), still open:** `ANTHROPIC_API_KEY` env var reservation in compose comments — keep for production-v1, or strip now? Not blocking either phase; defer.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phase A.0 completion summary (2026-05-07 23:50)
|
||||
|
||||
**Coder shift (noether) executed Phase A.0 in full:**
|
||||
|
||||
1. ✅ shim committed at `scripts/paliadin-shim` (commit `0248411`, repo-version-controlled)
|
||||
2. ✅ shim installed at `/home/m/.local/bin/paliadin-shim` on mRiver
|
||||
3. ✅ ed25519 keypair `paliad-prod` generated, public-key fingerprint `SHA256:5uV8v872F/IhJycjjq0crFue/emAYfw71N9bxTvkl9c`, private key staged at `~/.paliad-staging/paliad-prod-key` on mRiver (mode 600)
|
||||
4. ✅ `~/.ssh/authorized_keys` written with `command=`/`from=`/no-pty/no-port-forwarding/no-agent-forwarding/no-X11-forwarding/no-user-rc restrictions
|
||||
5. ✅ `ssh.socket` drop-in installed at `/etc/systemd/system/ssh.socket.d/paliad.conf`; both `:22` and `:22022` listening
|
||||
6. ✅ host key for `:22022` captured at `~/.paliad-staging/known_hosts` (fingerprint `SHA256:HPoUzy60Cb8yLERIBQcB2mHihNST3NaTODx5Ypd1XpA`)
|
||||
7. ✅ end-to-end SSH+shim+Claude run-turn validated from mLake → mRiver:22022 (3.4 s round-trip)
|
||||
8. ✅ `from="100.99.98.201"` rejection verified
|
||||
|
||||
**Three secrets ready for Dokploy registration** (m to copy from `~/.paliad-staging/` on mRiver):
|
||||
- `PALIADIN_SSH_PRIVATE_KEY` ← `cat ~/.paliad-staging/paliad-prod-key`
|
||||
- `PALIADIN_KNOWN_HOSTS` ← `cat ~/.paliad-staging/known_hosts`
|
||||
- `PALIADIN_REMOTE_HOST=100.99.98.203`, `PALIADIN_REMOTE_PORT=22022`, `PALIADIN_REMOTE_USER=m`
|
||||
|
||||
**Phase A.5 (traefik+host-mode test) and Phase A.6/A.7 (in-container SSH smoke + paliad/paliadin end-to-end) await m's hands** — they touch prod paliad.de.
|
||||
|
||||
**Phase B (Dockerfile + Go interface split + Dokploy secrets) is unblocked from a code perspective** — but should not merge until Phase A.5 confirms the host-mode networking trade-off is acceptable.
|
||||
|
||||
---
|
||||
|
||||
**Inventor design + coder Phase A.0 complete.** Awaiting m for Phase A.5 traefik validation before the coder writes the Go interface split.
|
||||
841
docs/design-profession-vs-project-role-2026-05-07.md
Normal file
841
docs/design-profession-vs-project-role-2026-05-07.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# Profession vs project responsibility — split `project_teams.role`
|
||||
|
||||
Inventor: kepler · Date: 2026-05-07 · Issue: m/paliad#6 (t-paliad-148)
|
||||
Branch: `mai/kepler/inventor-profession-vs`
|
||||
Status: **READY FOR REVIEW** — awaits m's go on the 12 open questions before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.project_teams.role` does two jobs at once: it labels a user's
|
||||
**career tier at the firm** (PA, Associate, Of Counsel) **and** it labels
|
||||
their **responsibility on this project** (Lead, Observer). m's bug report
|
||||
(2026-05-06): you don't redefine someone's profession when staffing them
|
||||
on a matter. The team-add dropdown should let you pick *responsibility*
|
||||
only; profession should come from the firm record.
|
||||
|
||||
This design splits the column into two:
|
||||
|
||||
1. **`paliad.users.profession`** — firm-wide career tier
|
||||
(`partner | of_counsel | associate | senior_pa | pa | paralegal | NULL`).
|
||||
Drives the t-138 approval ladder. NULL means "no firm tier" (external).
|
||||
2. **`paliad.project_teams.responsibility`** — per-project responsibility
|
||||
(`lead | member | observer | external`). Default `member`. Drives a
|
||||
simple gate — `lead` and `member` open authority; `observer` and
|
||||
`external` close it. Replaces the team-add dropdown values m
|
||||
complained about.
|
||||
|
||||
Approval ladder migrates from `project_teams.role` to a tuple
|
||||
**(profession, responsibility)** evaluated as: *level = profession_level
|
||||
if responsibility ∈ {lead, member} else 0*. Policy grammar
|
||||
(`required_role` single-value) stays unchanged from t-138.
|
||||
|
||||
Single migration 057. Backfills profession from the highest legacy
|
||||
`project_teams.role` per user. project_teams.role kept as a deprecated
|
||||
shadow column for one release, dropped in 058.
|
||||
|
||||
---
|
||||
|
||||
## §1 Problem & locked premises
|
||||
|
||||
### What m said (2026-05-06)
|
||||
|
||||
> "The Role should not be definable there. Whether a team member is PA
|
||||
> or Associate etc is not defined when adding existing members. Roles
|
||||
> for the project, maybe. But not the 'profession'."
|
||||
|
||||
### Locked decisions (m, 2026-05-06)
|
||||
|
||||
- **Profession is not redefined per project.** It comes from the user's
|
||||
firm-level record.
|
||||
- **Project-level role is meaningful.** Stays editable per project, but
|
||||
with a smaller value set focused on responsibility.
|
||||
- **Approval ladder must keep working** — t-138 just shipped. Whatever
|
||||
drives the ladder must still drive it.
|
||||
|
||||
### Three-axis principle (held since t-051)
|
||||
|
||||
> "Firm roles ≠ project roles ≠ tool roles."
|
||||
|
||||
Today's surfaces:
|
||||
|
||||
| Axis | Today's column | Today's values |
|
||||
|---|---|---|
|
||||
| Firm — display | `paliad.users.job_title` (free text) | "Counsel Knowledge Lawyer", "Junior Associate"… |
|
||||
| Firm — tool admin | `paliad.users.global_role` | `standard \| global_admin` |
|
||||
| Firm — partner-unit slot | `paliad.partner_unit_members.unit_role` | `lead \| attorney \| senior_pa \| pa \| paralegal` (per-unit, not firm-wide) |
|
||||
| Project — staffing | `paliad.project_teams.role` | **mixed: profession + responsibility** ← the bug |
|
||||
|
||||
The split adds a fourth, missing axis — **firm career tier** as a
|
||||
structured value that drives approval authority. It does not collapse
|
||||
job_title (free-text label is still useful) or unit_role (per-unit slot
|
||||
is still useful — see t-139 §11).
|
||||
|
||||
---
|
||||
|
||||
## §2 Verified live state (2026-05-07)
|
||||
|
||||
Probed `ydb` (paliad schema, port 11833) and current branch:
|
||||
|
||||
- `paliad.project_teams` CHECK on `role`: `lead, associate, pa,
|
||||
of_counsel, local_counsel, expert, observer, senior_pa` (from
|
||||
migration 054).
|
||||
- `paliad.project_teams` row count: **3 rows, all `role='lead'`**.
|
||||
- `paliad.partner_unit_members` CHECK on `unit_role`: `lead, attorney,
|
||||
senior_pa, pa, paralegal`. Row count: **20 rows, all
|
||||
`unit_role='attorney'`** (the default — nobody has overridden it
|
||||
yet).
|
||||
- `paliad.users` columns include `job_title` (text NULL), `global_role`
|
||||
(text NOT NULL DEFAULT 'standard'). No profession column.
|
||||
- `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
|
||||
ladder helper, used in 4 SQL sites in `approval_service.go`.
|
||||
- `paliad.approval_role_from_unit_role(text) RETURNS text IMMUTABLE` —
|
||||
bridges unit_role → ladder values for derived authority.
|
||||
- t-138 (commit `e2e1381`) and t-139 phases 1–3 all merged on `main`.
|
||||
Migration tracker at 56 (next is **057**).
|
||||
|
||||
**Implication of the live data**: backfill is essentially trivial. Three
|
||||
project_teams rows. Twenty partner_unit_members rows. The risk surface
|
||||
of the migration is the SQL rewiring, not the data movement.
|
||||
|
||||
### Inventory of references to migrate
|
||||
|
||||
| File | Site | What it reads |
|
||||
|---|---|---|
|
||||
| `internal/services/team_service.go:53,93,103,122,159` | INSERT/SELECT/validate | `pt.role` for read+write of project membership. |
|
||||
| `internal/services/derivation_service.go:118,127,314,383,403` | EffectiveProjectRole + manage gate | `pt.role` for ancestor walk + `RoleLead` for project-lead-can-manage check. |
|
||||
| `internal/services/approval_service.go:103,411,751,854` | canApprove + ListPending + bell badge + deadlock check | `paliad.approval_role_level(pt.role)` — 4 SQL sites. |
|
||||
| `internal/services/reminder_service.go:317,330` | reminder digest filter | `pt.role = 'lead'` — project-responsibility check. |
|
||||
| `internal/services/deadline_service.go:695` | legacy authority check | `pt.role IN ('admin', 'lead')` — `'admin'` is dead since t-051; this is half-broken already. |
|
||||
| `internal/services/project_service.go:486` | creator-as-lead INSERT | `INSERT … role='lead'`. |
|
||||
| `internal/services/approval_levels.go:70` | Go-side `levelOf()` | Mirror of SQL ladder. Must change with the SQL. |
|
||||
| `internal/services/project_service.go:57-66` | `RoleLead` etc. constants | Used in 14 places across services. |
|
||||
| `internal/db/migrations/055_hierarchy_aggregation.up.sql:84,92` | can_see_project body | `pt.role = 'lead'`. |
|
||||
| `frontend/src/projects-detail.tsx:124-132` | team-add dropdown | The 7 mixed options m complained about. |
|
||||
| `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` | render + read of role | i18n `projects.team.role.*`. |
|
||||
| `frontend/src/client/i18n.ts:1139-1145, 2949-2955` | role translations | DE+EN keys. |
|
||||
|
||||
This is a wide rewrite but it's mechanical — the column boundary is
|
||||
clean, the call sites are narrow, and the live data is small.
|
||||
|
||||
---
|
||||
|
||||
## §3 Sub-design A — Profession axis (Q1, Q2, Q3, Q12)
|
||||
|
||||
### Q1 — Where does profession live? Recommendation: **(a) new `paliad.users.profession` column**
|
||||
|
||||
Three candidates from issue body:
|
||||
|
||||
(a) New `paliad.users.profession` column (firm-wide, simple).
|
||||
(b) Reuse `paliad.partner_unit_members.unit_role` (already added by
|
||||
t-139 Phase 2; only set when the user is in a unit).
|
||||
(c) New separate `paliad.user_professions(user_id, profession,
|
||||
valid_from)` table for history.
|
||||
|
||||
**Recommend (a).**
|
||||
|
||||
Rationale:
|
||||
|
||||
- (b) breaks for users not in a partner unit. Today: 31 users, ~20 in
|
||||
units. The other 11 (admins, externals, future hires) have no
|
||||
unit_role. Profession needs to be defined for everyone or the
|
||||
approval ladder gets gappy.
|
||||
- (b) creates ambiguity if a user joins multiple units with different
|
||||
unit_roles (legal under the t-139 schema). Picking "the highest" or
|
||||
"the first" hides the data confusion. A firm-wide column is
|
||||
unambiguous by construction.
|
||||
- (b) re-couples the per-unit axis to the firm-wide axis. t-139 §11
|
||||
explicitly kept `unit_role` per-unit to preserve the three-axis
|
||||
principle. Reusing it for firm-wide authority breaks that invariant.
|
||||
- (c) overengineered for v1. Profession changes when an HR promotion
|
||||
fires — no audit, no time-slice. If history becomes a requirement,
|
||||
add the table later (out-of-scope per issue body).
|
||||
|
||||
(a) is one column, one CHECK, no joins on the read path, no per-unit
|
||||
ambiguity. Drop-in replacement for the slot in the approval ladder.
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN profession text NULL
|
||||
CHECK (profession IS NULL OR profession IN (
|
||||
'partner', 'of_counsel', 'associate',
|
||||
'senior_pa', 'pa', 'paralegal'
|
||||
));
|
||||
|
||||
CREATE INDEX users_profession_idx ON paliad.users (profession);
|
||||
```
|
||||
|
||||
NULL is a valid value: it means "no firm career tier" (e.g. external
|
||||
local counsel signed up via invitation, or admin accounts that aren't
|
||||
practicing lawyers). NULL → ladder level 0 → ineligible to approve.
|
||||
|
||||
`job_title` (free-text display) and `global_role` (tool admin) remain
|
||||
untouched. Three firm-axis columns:
|
||||
|
||||
| Column | Purpose | Approval-relevant? |
|
||||
|---|---|---|
|
||||
| `users.job_title` | Free-text display label ("Counsel Knowledge Lawyer") | No |
|
||||
| `users.profession` | Structured career tier (drives ladder) | **Yes** |
|
||||
| `users.global_role` | Tool admin gate (`standard \| global_admin`) | Override only |
|
||||
|
||||
### Q2 — Profession values Recommendation: **`partner | of_counsel | associate | senior_pa | pa | paralegal`** (NULL = external)
|
||||
|
||||
The t-138 ladder defined 5 active levels. Today they are mixed
|
||||
project-level + profession-level:
|
||||
|
||||
| Today | Level | Belongs on which axis? |
|
||||
|---|---|---|
|
||||
| `lead` | 5 | **project responsibility** (the lawyer in charge of THIS matter) |
|
||||
| `of_counsel` | 4 | profession |
|
||||
| `associate` | 3 | profession |
|
||||
| `senior_pa` | 2 | profession |
|
||||
| `pa` | 1 | profession |
|
||||
| `local_counsel` | 0 | project responsibility (`external`) |
|
||||
| `expert` | 0 | project responsibility (`external`) |
|
||||
| `observer` | 0 | project responsibility |
|
||||
|
||||
Removing the project-axis values from the ladder leaves 4 profession
|
||||
tiers (of_counsel, associate, senior_pa, pa). But "lead" was implicitly
|
||||
"a partner is leading this matter", so profession needs **`partner`**
|
||||
at level 5 to preserve the ceiling.
|
||||
|
||||
Add **`paralegal`** at level 0 (mirrors `partner_unit_members.unit_role`
|
||||
which already has it; current `approval_role_from_unit_role` already
|
||||
maps it to `observer`/level 0).
|
||||
|
||||
Final enum (6 values + NULL):
|
||||
|
||||
| Profession | Ladder level | Notes |
|
||||
|---|---|---|
|
||||
| `partner` | 5 | Replaces the project-level `lead` as the firm-tier ceiling. |
|
||||
| `of_counsel` | 4 | unchanged |
|
||||
| `associate` | 3 | unchanged; default for new firm members |
|
||||
| `senior_pa` | 2 | unchanged |
|
||||
| `pa` | 1 | unchanged |
|
||||
| `paralegal` | 0 | New — present in unit_role; ineligible to approve. |
|
||||
| NULL | 0 | "External / no firm tier." Approval-ineligible. |
|
||||
|
||||
**Why not include `senior_associate`, `counsel`, `trainee`, etc.** that
|
||||
appear in the existing `i18n.team.role.*` keys (free-text user
|
||||
directory): those values don't change the ladder level
|
||||
(senior_associate = associate tier; counsel = of_counsel tier; trainee
|
||||
= ineligible). Adding them inflates the enum without adding
|
||||
authority-relevant distinctions. They live in `job_title` (free text)
|
||||
where they belong. If HR later needs structured senior_associate vs
|
||||
associate, the migration is one CHECK alter; the call sites are zero
|
||||
because the ladder only sees levels.
|
||||
|
||||
**External roles (`local_counsel`, `expert`)** in the issue body are
|
||||
project-only labels — they describe what a person *is on this matter*,
|
||||
not a firm career tier. They land in §4 as `responsibility='external'`.
|
||||
Their profession is NULL.
|
||||
|
||||
### Q3 — Onboarding flow Recommendation: **required-on-invite, default suggestion = `associate`, admin-editable later**
|
||||
|
||||
Three options:
|
||||
|
||||
- (i) Auto-default to `associate` with admin-edit later.
|
||||
- (ii) Required-on-invite: inviting colleague picks profession.
|
||||
- (iii) User picks own profession on first login.
|
||||
|
||||
**Recommend (ii) with default = `associate`.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- (i) recreates the bug m just complained about, in slow motion. Every
|
||||
PA invited gets shown as "associate" until someone notices and
|
||||
edits. The whole point of this work is "profession is real, set it
|
||||
honestly".
|
||||
- (iii) is wrong: you don't redefine your own firm tier; HR/the firm
|
||||
does. Self-pick also breaks the audit (anyone could promote
|
||||
themselves).
|
||||
- (ii) is one extra `<select>` on the existing invite form (already
|
||||
rebuilt for t-paliad-141). The inviter is a colleague — they know
|
||||
whether they're inviting a PA or an associate.
|
||||
- Default `associate` makes the most common case one click. PAs and
|
||||
Of Counsels are explicit choices, not silent demotions.
|
||||
|
||||
**External invitees** (local counsel, expert): inviter sets
|
||||
`responsibility='external'` on the project; profession defaults to
|
||||
NULL (not asked) — the form hides the profession field when
|
||||
responsibility=external. Admin can fill profession later if the
|
||||
external collaborator becomes a paliad-tracked firm member.
|
||||
|
||||
### Q12 — Bulk add / invite-new flow Recommendation: **profession capture on invite; NULL allowed; admin-edits later**
|
||||
|
||||
The existing invite-new-user flow (`team-user-invite-btn` →
|
||||
`/api/team/invite-new`) accepts email + display_name today. After this
|
||||
change:
|
||||
|
||||
- Invite form gains a profession `<select>` (6 values + "Extern (keine
|
||||
Profession)").
|
||||
- Default selected: `associate`.
|
||||
- Submit creates `paliad.users` row with the picked profession +
|
||||
`paliad.project_teams` row with the picked responsibility (default
|
||||
`member`).
|
||||
- "Extern" sets `responsibility='external'` on the project_teams row,
|
||||
profession=NULL on users.
|
||||
|
||||
**No bulk-add UI exists today** — out of scope. If/when one ships, it
|
||||
inherits the same per-row profession field.
|
||||
|
||||
**Admin re-edit**: `/admin/team` page (already shipped t-paliad-050)
|
||||
gets a Profession column with inline-edit dropdown. Position next to
|
||||
job_title. No last-admin guard needed (profession is not a tool gate).
|
||||
|
||||
---
|
||||
|
||||
## §4 Sub-design B — Project responsibility axis (Q4, Q5, Q6, Q11)
|
||||
|
||||
### Q4 — Value set Recommendation: **`lead | member | observer | external`**
|
||||
|
||||
Issue body suggests this set. Locking it.
|
||||
|
||||
| Value | Meaning | Edit/approve authority |
|
||||
|---|---|---|
|
||||
| `lead` | The responsible lawyer/partner for this matter. Also has project-management permissions (manage settings, attach partner units — already wired in derivation_service.go). | Full (subject to profession ceiling). |
|
||||
| `member` | Staffed on this matter at their profession's level. | Full (subject to profession ceiling). |
|
||||
| `observer` | Read-only awareness; no edit/approve authority. | None. |
|
||||
| `external` | Non-firm collaborator (local counsel, expert witness). May edit per project policy, but cannot satisfy the firm-tier approval ladder. | Edit yes, approve no. |
|
||||
|
||||
**Why not collapse `external` into `observer`**: externals can actively
|
||||
write (local counsel files briefs, experts upload reports). Observers
|
||||
can't. The two are distinct read/write profiles and conflating them
|
||||
loses information.
|
||||
|
||||
**Why not add `pa-on-this-project` etc.** — that's profession × project,
|
||||
exactly what we just split. Once split, never re-mix.
|
||||
|
||||
### Q5 — Default value Recommendation: **`member`**
|
||||
|
||||
m's intuition is right. Lock.
|
||||
|
||||
The team-add form's default selection is `member`. The project creator
|
||||
is auto-added as `lead` (already coded in `project_service.go:486` —
|
||||
just rename the inserted column from `role='lead'` to
|
||||
`responsibility='lead'`).
|
||||
|
||||
### Q6 — Display Recommendation: **3 columns: Name · Profession (read-only badge) · Responsibility (editable inline)**, plus the existing Herkunft column
|
||||
|
||||
Layout for the team table on `/projects/{id}` Tab:
|
||||
|
||||
```
|
||||
| Name | Profession | Responsibility (edit) | Herkunft | Aktion |
|
||||
| Anna Schmidt | [PA] | [Lead ▾] | direkt | 🗑 |
|
||||
| Max Mustermann| [Associate] | [Member ▾] | über X-Unit | 🗑 |
|
||||
| Carla Smith | (extern) | [External] | direkt | 🗑 |
|
||||
```
|
||||
|
||||
- **Profession** column: read-only `.projekt-team-profession` pill (CSS
|
||||
variant of existing `.projekt-team-role`). Click for global_admin
|
||||
opens `/admin/team#user-{id}` for inline edit. For non-admins, a
|
||||
hover tooltip explains "Profession wird im Firmenprofil gepflegt".
|
||||
- **Responsibility** column: existing inline-edit pattern (`.entity-row
|
||||
select`) — reuses the t-paliad-141 inline-edit affordance. Edit
|
||||
permission = project lead OR global_admin.
|
||||
- NULL profession renders as `(extern)` or `(keine Profession)`
|
||||
depending on `responsibility`.
|
||||
|
||||
Inline prose elsewhere (Verlauf entries, inbox rows, email reminders):
|
||||
**"Anna Schmidt (PA) — Lead"** — profession in parens, responsibility
|
||||
after a dash. Explicit and parseable.
|
||||
|
||||
For the audit trail (`paliad.project_events`), emit
|
||||
`team_member_added` with `metadata = {responsibility: 'member',
|
||||
profession_at_time: 'pa'}` so historic rendering survives a profession
|
||||
change.
|
||||
|
||||
### Q11 — Team table layout post-fix Recommendation: **3-column tabular layout above; tooltip-only profession is rejected**
|
||||
|
||||
Two alternatives the issue posed:
|
||||
|
||||
- **Hover-only profession** ("Anna Schmidt — Lead", profession in
|
||||
tooltip badge): rejected. Profession is too important to hide. The
|
||||
whole point of the split is to make profession honestly visible.
|
||||
- **3-column tabular**: chosen. Matches the existing `.entity-table`
|
||||
pattern; profession is glanceable.
|
||||
|
||||
Tooltip is still useful as *secondary* signal: hover the profession
|
||||
badge → "PA — gesetzt im Firmenprofil. (Edit by global_admin only)".
|
||||
|
||||
The team-add form (the bug surface m complained about) loses the
|
||||
mixed-axis dropdown. New form:
|
||||
|
||||
```
|
||||
[ User autocomplete ▾ ] ← picks Anna Schmidt
|
||||
Anna Schmidt (PA) ← shown beneath, read from users.profession
|
||||
[ Responsibility: Member ▾ ] ← only dropdown left; default Member
|
||||
[ Cancel ] [ Hinzufügen ]
|
||||
```
|
||||
|
||||
**If the picked person has profession=NULL:** show a yellow warning
|
||||
under the profession line: "*Anna hat keine Profession gesetzt — sie
|
||||
kann keine 4-Augen-Genehmigungen erteilen. Admin im Firmenprofil
|
||||
nachtragen.*" Doesn't block the add, just informs.
|
||||
|
||||
---
|
||||
|
||||
## §5 Sub-design C — Approval ladder rename + migration (Q7, Q8, Q9, Q10)
|
||||
|
||||
### Q7 vs Q8 — Tuple-gated ladder Recommendation: **Q7 (rename to profession) with project-responsibility as a binary gate**
|
||||
|
||||
Two views the issue posed:
|
||||
|
||||
- **Q7**: ladder migrates from `project_teams.role` → `users.profession`.
|
||||
Project responsibility goes elsewhere; the ladder is purely
|
||||
profession-driven.
|
||||
- **Q8**: ladder becomes a tuple `(profession,
|
||||
project_responsibility)` — finer policies, e.g. "associate-level
|
||||
lawyer who is at least a member on this project".
|
||||
|
||||
**Recommend Q7-with-gate**: the ladder is profession-driven, and
|
||||
project responsibility acts as a *binary gate* (open/closed) rather
|
||||
than a separate dimension in the policy grammar.
|
||||
|
||||
Effective level for user U on project P:
|
||||
|
||||
```
|
||||
profession_level = approval_role_level(U.profession) -- 0 if NULL
|
||||
responsibility = project_teams.responsibility on P (direct or ancestor)
|
||||
gate_open = responsibility IN ('lead', 'member')
|
||||
|
||||
effective_level = profession_level if gate_open else 0
|
||||
```
|
||||
|
||||
For derived (partner-unit) authority (t-139):
|
||||
|
||||
```
|
||||
derived_role = approval_role_from_unit_role(unit_role)
|
||||
when ppu.derive_grants_authority = true
|
||||
effective_level = max over all sources (direct, ancestor, derived)
|
||||
```
|
||||
|
||||
(The "max" is operative because a user might be a `member` of one
|
||||
project at profession=PA, AND derive-with-authority into the same
|
||||
project via a partner-unit attachment with unit_role=senior_pa. Take
|
||||
the higher.)
|
||||
|
||||
**Why not pure Q8?**
|
||||
|
||||
- Pure tuple-grammar means policies look like `required_role='associate'
|
||||
AND required_responsibility='lead'`. Fine for power users; nobody
|
||||
has asked. We can add the responsibility dimension to
|
||||
`approval_policies` later (one new nullable column) if the firm
|
||||
wants finer rules. v1 stays single-dimension, matching m's t-138
|
||||
Q3 lock ("per-(project, entity_type, lifecycle_event)
|
||||
required_role").
|
||||
- Pure tuple also breaks Verlauf/audit phrasing — the audit currently
|
||||
reads "Genehmigung erforderlich: Associate-Tier oder höher", which
|
||||
stays clean under Q7-with-gate. Tuple grammar would need
|
||||
"Associate-Tier UND mindestens Mitglied".
|
||||
|
||||
**Why not pure Q7 without the gate?**
|
||||
|
||||
- Without the gate, an `observer` who happens to be a Partner could
|
||||
approve. That defeats the project-level call. The whole reason
|
||||
someone is set as observer is "you're not authoritative here, even
|
||||
though you're senior". The gate restores that semantics.
|
||||
- `external` (local counsel) without a gate would also approve via
|
||||
their own firm tier — except their profession is NULL, so they're
|
||||
level 0 anyway. The gate is defense-in-depth there: if a future
|
||||
external is given profession=of_counsel by mistake, the
|
||||
responsibility=external still keeps them at level 0.
|
||||
|
||||
**Implementation site**: a new SQL function
|
||||
`paliad.user_project_authority_level(_user_id uuid, _project_id uuid)
|
||||
RETURNS int IMMUTABLE` encapsulates the (profession, responsibility,
|
||||
derivation) computation. Replaces inline
|
||||
`paliad.approval_role_level(pt.role)` at the 4
|
||||
`approval_service.go` SQL sites. Plus a Go mirror
|
||||
`UserProjectAuthorityLevel(ctx, userID, projectID) int` for callers
|
||||
that need it without a SQL roundtrip (none today, but the
|
||||
DerivationService.EffectiveProjectRole becomes a thin wrapper).
|
||||
|
||||
Policy grammar stays exactly as t-138 designed. `required_role` is a
|
||||
profession value (`partner`, `of_counsel`, `associate`, `senior_pa`,
|
||||
`pa`). The CHECK on `approval_policies.required_role` is updated to
|
||||
the new enum (drop 'lead' — was the project-level value — and rename
|
||||
nothing; the SQL ladder values are 1:1 except the ceiling). Existing
|
||||
policy rows get backfilled `lead → partner` (the only mapping that
|
||||
changes).
|
||||
|
||||
### Q9 — Backfill plan Recommendation: **highest-tier-observed per user; `lead/of_counsel/associate/senior_pa/pa → matching profession`; `local_counsel/expert/observer → NULL`**
|
||||
|
||||
Backfill rules:
|
||||
|
||||
**Profession** (firm-wide, one row per user):
|
||||
|
||||
For each user with at least one `paliad.project_teams` row:
|
||||
|
||||
```
|
||||
profession = highest tier among all (direct) project_teams rows
|
||||
where:
|
||||
legacy 'lead' → 'partner' (level 5)
|
||||
legacy 'of_counsel' → 'of_counsel'(level 4)
|
||||
legacy 'associate' → 'associate' (level 3)
|
||||
legacy 'senior_pa' → 'senior_pa' (level 2)
|
||||
legacy 'pa' → 'pa' (level 1)
|
||||
legacy 'local_counsel' → IGNORED
|
||||
legacy 'expert' → IGNORED
|
||||
legacy 'observer' → IGNORED
|
||||
```
|
||||
|
||||
If after ignoring project-only labels the user has no firm-tier row →
|
||||
profession = NULL.
|
||||
|
||||
For users with NO project_teams rows → profession = NULL too. Admin
|
||||
edits at `/admin/team` if those users are firm members (the 11
|
||||
unit-only users in current data).
|
||||
|
||||
**Tie-break**: pick the highest level. If a user is `lead` on Project A
|
||||
and `pa` on Project B, profession = `partner` (level 5 > level 1).
|
||||
This matches m's "highest-tier observed" rule from the issue body.
|
||||
|
||||
**Edge case — only `observer` rows**: the user has exactly one
|
||||
`observer` row across all projects. Profession = NULL (no firm tier
|
||||
inferable from the data). Admin will need to set it.
|
||||
|
||||
**Edge case — `local_counsel` rows only**: user is external. Profession
|
||||
= NULL. Their project_teams.responsibility row will be 'external'
|
||||
(see below).
|
||||
|
||||
**Responsibility** (per project_teams row):
|
||||
|
||||
```
|
||||
legacy 'lead' → 'lead'
|
||||
legacy 'observer' → 'observer'
|
||||
legacy 'local_counsel' → 'external'
|
||||
legacy 'expert' → 'external'
|
||||
legacy 'associate' → 'member'
|
||||
legacy 'pa' → 'member'
|
||||
legacy 'of_counsel' → 'member'
|
||||
legacy 'senior_pa' → 'member'
|
||||
```
|
||||
|
||||
This preserves m's stated rules:
|
||||
|
||||
- `lead` → `lead`
|
||||
- `observer` → `observer`
|
||||
- everything else (firm tier) → `member` (their authority is now
|
||||
encoded in their profession; the project row just says "they're
|
||||
staffed")
|
||||
|
||||
External labels (`local_counsel`, `expert`) get
|
||||
`responsibility='external'`. Their profession remains NULL (the
|
||||
backfill above ignores them for profession purposes).
|
||||
|
||||
**Live data sanity check**: today there are 3 project_teams rows, all
|
||||
`role='lead'`. Backfill produces:
|
||||
|
||||
- 3 users get `profession='partner'`.
|
||||
- 3 project_teams rows get `responsibility='lead'`.
|
||||
|
||||
All other users (28 of 31) get `profession=NULL` until admin edits
|
||||
them at `/admin/team`. Acceptable — the firm has known they need an
|
||||
audit pass over user records since t-051; this surfaces it cleanly.
|
||||
|
||||
### Q10 — Down-migration safety Recommendation: **reversible with documented data loss on edge cases**
|
||||
|
||||
Down-migration steps (`057_down`):
|
||||
|
||||
1. Re-derive `project_teams.role` from `(responsibility, profession)`:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.project_teams pt
|
||||
SET role = CASE
|
||||
WHEN pt.responsibility = 'lead' THEN 'lead'
|
||||
WHEN pt.responsibility = 'observer' THEN 'observer'
|
||||
WHEN pt.responsibility = 'external' THEN 'local_counsel'
|
||||
WHEN pt.responsibility = 'member' THEN COALESCE(
|
||||
(SELECT u.profession FROM paliad.users u WHERE u.id = pt.user_id),
|
||||
'associate'
|
||||
)
|
||||
END;
|
||||
```
|
||||
|
||||
- `external` always maps back to `local_counsel` (most common
|
||||
pre-split external label; `expert` is rarer and lossy).
|
||||
- `member` with profession=`partner` maps back to… ambiguous.
|
||||
Pre-split there was no firm-tier `partner` row in
|
||||
`project_teams`. Document data loss: maps to `of_counsel` (next
|
||||
highest legacy value). If the down is run, the partner re-appears
|
||||
as of_counsel on that project. Acceptable for a rollback.
|
||||
- `member` with profession=`paralegal` maps back to `pa` (closest
|
||||
legacy fit; `paralegal` was never a `project_teams.role` value).
|
||||
- `member` with profession=NULL maps back to `associate` (safe
|
||||
default, matches the legacy `RoleAssociate` default).
|
||||
|
||||
2. DROP COLUMN `paliad.users.profession`.
|
||||
3. DROP COLUMN `paliad.project_teams.responsibility`.
|
||||
4. Drop `paliad.user_project_authority_level` function.
|
||||
5. Restore `approval_service.go` SQL sites to inline
|
||||
`approval_role_level(pt.role)`.
|
||||
|
||||
Down-migration is best-effort. Documented data loss in `057_down.sql`
|
||||
comments. The Go code on `main` doesn't need to support both states
|
||||
(paliad doesn't have multi-version-deployed history); a down is a
|
||||
manual rollback path.
|
||||
|
||||
**Phasing**: `project_teams.role` stays on the table as a deprecated
|
||||
shadow column for one release (migration 057 keeps it; migration 058 —
|
||||
follow-up ticket — drops it after Go code is fully migrated). This
|
||||
means even in the worst case, a fast down doesn't have to recompute
|
||||
`role`; it just drops the new columns and keeps the old.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan — single migration 057
|
||||
|
||||
Filename: `internal/db/migrations/057_profession_vs_responsibility.up.sql`
|
||||
|
||||
Sections:
|
||||
|
||||
1. ADD `paliad.users.profession`.
|
||||
2. ADD `paliad.project_teams.responsibility`.
|
||||
3. CREATE `paliad.user_project_authority_level(user_id, project_id)`
|
||||
function.
|
||||
4. UPDATE `paliad.approval_policies.required_role` CHECK to add
|
||||
`'partner'` and drop `'lead'`. Backfill `'lead'` → `'partner'` in
|
||||
any existing rows.
|
||||
5. Backfill `users.profession` per Q9.
|
||||
6. Backfill `project_teams.responsibility` per Q9.
|
||||
7. UPDATE `paliad.can_see_project` body — replace `pt.role = 'lead'`
|
||||
with `pt.responsibility = 'lead'`. Function CASCADE-rebuild not
|
||||
needed (only function body changes).
|
||||
8. UPDATE the comment on `paliad.approval_role_level` to point at
|
||||
`users.profession` instead of `project_teams.role`.
|
||||
|
||||
`project_teams.role` is **kept** in this migration (deprecated, not
|
||||
read by any new code). Drop in follow-up migration 058 after Go code
|
||||
fully migrates and is verified live.
|
||||
|
||||
### Service-layer migration (single PR alongside 057)
|
||||
|
||||
Files to edit:
|
||||
|
||||
- `internal/services/team_service.go` — INSERT/SELECT/validate the new
|
||||
`responsibility` column. `isValidRole` becomes
|
||||
`isValidResponsibility` with new enum.
|
||||
- `internal/services/derivation_service.go` — `requireWritePermission`
|
||||
reads `pt.responsibility = 'lead'` instead of `pt.role = 'lead'`.
|
||||
`EffectiveProjectRole` (used by t-138 derived authority) replaced by
|
||||
`UserProjectAuthorityLevel` (returns int from the SQL function +
|
||||
source string). `ListAttachedUnits`, `ListDerivedMembers` unchanged
|
||||
(they don't touch the ladder column).
|
||||
- `internal/services/approval_service.go` — 4 SQL sites switch from
|
||||
`paliad.approval_role_level(pt.role)` to
|
||||
`paliad.user_project_authority_level(pt.user_id, $project_id)`.
|
||||
Self-approval CHECK and policy lookup stay identical.
|
||||
- `internal/services/approval_levels.go` — Go-side `levelOf()` becomes
|
||||
`professionLevel()`; new helper `responsibilityOpensGate()`.
|
||||
`RoleSeniorPA` constant stays (still a valid profession value,
|
||||
reused). New constants `ProfessionPartner`, `ProfessionOfCounsel`,
|
||||
`ProfessionAssociate`, `ProfessionSeniorPA`, `ProfessionPA`,
|
||||
`ProfessionParalegal`. New constants `ResponsibilityLead`,
|
||||
`ResponsibilityMember`, `ResponsibilityObserver`,
|
||||
`ResponsibilityExternal`.
|
||||
- `internal/services/project_service.go:486` — INSERT writes
|
||||
`responsibility='lead'` (creator-as-lead). Old
|
||||
`RoleLead`/`RoleAssociate`/etc constants stay as aliases for one
|
||||
release to ease grep diffs; mark deprecated.
|
||||
- `internal/services/reminder_service.go:317,330` —
|
||||
`pt.role = 'lead'` → `pt.responsibility = 'lead'`.
|
||||
- `internal/services/deadline_service.go:695` —
|
||||
`pt.role IN ('admin', 'lead')` → `pt.responsibility = 'lead'`.
|
||||
(`'admin'` was already dead since t-051; this is also a small
|
||||
cleanup.)
|
||||
- `internal/services/user_service.go` — onboarding/invite code
|
||||
accepts a `profession` arg, stores on insert.
|
||||
- `internal/handlers/team.go` (and friends) — JSON shape change:
|
||||
`ProjectTeamMember` now exposes `responsibility` instead of `role`,
|
||||
embeds `User.Profession`.
|
||||
- `internal/models/models.go` — `ProjectTeamMember.Role` → `.Responsibility`;
|
||||
`User` gains `.Profession *string`.
|
||||
|
||||
### Frontend migration (same PR)
|
||||
|
||||
- `frontend/src/projects-detail.tsx:124-132` — replace 7-option mixed
|
||||
dropdown with 4-option responsibility-only dropdown
|
||||
(`lead | member | observer | external`). Default `member`.
|
||||
- `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` — render
|
||||
3-column team table. New `.projekt-team-profession` CSS pill +
|
||||
i18n keys `projects.team.profession.partner` …
|
||||
`projects.team.profession.paralegal`. New i18n keys
|
||||
`projects.team.responsibility.lead` … `.external` (replace
|
||||
`projects.team.role.*`).
|
||||
- `frontend/src/client/team.ts` — `/team` directory page: respect new
|
||||
profession column for grouping. Falls back to job_title when
|
||||
profession=NULL (existing free-text behaviour preserved for
|
||||
externals).
|
||||
- `frontend/src/admin-team.tsx` + `client/admin-team.ts` — add
|
||||
Profession column with inline-edit dropdown.
|
||||
- `frontend/src/onboarding.tsx` — invite flow gains a profession
|
||||
`<select>`.
|
||||
- ~30 new i18n keys DE+EN.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/services/team_service_test.go` — happy path on
|
||||
AddMember/RemoveMember with new responsibility values; reject
|
||||
invalid values.
|
||||
- `internal/services/approval_service_test.go` — extend
|
||||
table-driven ladder tests to cover the
|
||||
(profession, responsibility) tuple. Cases: `partner+observer = 0`,
|
||||
`pa+lead = 1`, `null+member = 0`, derived+responsibility=external
|
||||
combinations.
|
||||
- `internal/services/migration_057_test.go` — live-DB integration
|
||||
test (skipped without `TEST_DATABASE_URL`): apply migration on a
|
||||
seeded snapshot, assert backfill produces expected
|
||||
(profession, responsibility) pairs.
|
||||
|
||||
---
|
||||
|
||||
## §7 Implementation phasing
|
||||
|
||||
**Single PR, 6 commits** — the schema + service + frontend are tightly
|
||||
coupled. Splitting risks half-broken intermediate states (the bug
|
||||
report itself is about a half-broken intermediate state).
|
||||
|
||||
1. Migration 057 (schema + backfill + new SQL function). No code
|
||||
changes — server still reads `pt.role`. Verify backfill on live DB
|
||||
via BEGIN/ROLLBACK.
|
||||
2. ApprovalService + DerivationService rewire. Tests updated. Build +
|
||||
test green. Server reads from new SQL function but writes still go
|
||||
to `pt.role` (will fix in commit 3).
|
||||
3. TeamService + UserService rewire. INSERT writes
|
||||
`responsibility=...`. Reads return `responsibility`. Models
|
||||
updated. JSON schema change.
|
||||
4. Frontend rewire — team-add dropdown, team table, admin-team,
|
||||
onboarding. New i18n keys.
|
||||
5. Reminder + Deadline service touch-ups + can_see_project body
|
||||
refresh.
|
||||
6. Lint + grep sweep — kill any remaining `pt.role` references that
|
||||
should have been migrated. Add a deprecation comment to the
|
||||
`RoleLead`/`RoleAssociate` Go constants pointing at the new ones.
|
||||
|
||||
**Follow-up ticket (out of scope for this PR)**: t-paliad-149 —
|
||||
migration 058 to DROP COLUMN `project_teams.role` after one release of
|
||||
soak time on main. Trivial when the time comes; just keeps this PR
|
||||
clean.
|
||||
|
||||
**Recommended implementer**: any pattern-fluent coder. **NOT cronus**
|
||||
(retired from paliad per memory directive). Sonnet work — 70% of the
|
||||
diff is mechanical rename, 30% is the new SQL function + 4 ladder-site
|
||||
rewrites + the new team-table layout. The substrate is well-trodden
|
||||
(t-051 split established the pattern; t-138/t-139 left clean call
|
||||
sites to migrate from).
|
||||
|
||||
---
|
||||
|
||||
## §8 Trade-offs flagged
|
||||
|
||||
1. **One migration touches both axes at once.** A pure-additive
|
||||
migration (add columns, leave `role`) would be safer-feeling, but
|
||||
then the team-add dropdown bug stays open (the UX lie m hates is
|
||||
still on screen until commit 4). I prefer one PR that ships the
|
||||
fix end-to-end, with `project_teams.role` deprecated-shadow for
|
||||
one release as the safety net.
|
||||
2. **Profession=NULL semantics are load-bearing.** NULL means "no
|
||||
firm tier" → ladder level 0 → ineligible. If a developer later
|
||||
adds a fast-path that defaults NULL→`associate` for "convenience",
|
||||
externals would silently gain approval rights. Mitigation: explicit
|
||||
helper `professionLevel(*string) int` that returns 0 for NULL with
|
||||
a comment naming the trap. Add a unit test `TestProfessionLevel_NilIsZero`.
|
||||
3. **`partner` is the new ceiling but `lead` is no longer a profession**.
|
||||
The mental jump for users: "Lead" was the highest in the dropdown;
|
||||
now "Partner" is. Renaming is honest but a moment of surprise.
|
||||
Mitigation: i18n keys carry over the lead-on-this-project sense via
|
||||
`projects.team.responsibility.lead` so the word "Lead" stays
|
||||
visible exactly where it should — the project axis. Profession's
|
||||
"Partner" appears in firm-context surfaces (admin/team, tooltips).
|
||||
4. **Tuple-gated ladder vs pure-tuple grammar.** Choosing
|
||||
responsibility as a binary gate means a future "must be a member,
|
||||
not just having visibility" rule is easy. A future "must be lead
|
||||
AND of_counsel-tier or higher" rule needs a new dimension on
|
||||
`approval_policies` (new nullable column). Acceptable: zero
|
||||
policies today need it; cheap to add when one does.
|
||||
5. **Backfill produces 28 NULL professions** out of 31 users (the
|
||||
ones not in any project_teams row). After ship, `/admin/team` will
|
||||
show a warning column "Profession nicht gesetzt" until admin
|
||||
completes the audit. This is honest visibility of pre-existing data
|
||||
debt rather than papering over with a guessed default.
|
||||
6. **`approval_role_from_unit_role` doesn't change** but its callers
|
||||
(the derived-authority SQL branches in approval_service.go) need to
|
||||
move from "compare against `pt.role`" to "compare against
|
||||
`users.profession` of the project_teams row's user". Mechanical;
|
||||
listed in §6 file inventory.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope (v1)
|
||||
|
||||
- Replacing the partner-unit-derivation mechanism (t-139 Phase 2) —
|
||||
derivation stays exactly as designed.
|
||||
- A full firm-roles / hierarchy / org-chart feature — this design adds
|
||||
one structured column (profession) and nothing more.
|
||||
- Multi-profession (paralegal-turned-associate scenario). One
|
||||
profession per user; admin edits when promoted.
|
||||
- Time-sliced profession history (who was a PA in 2024). Out per
|
||||
issue body.
|
||||
- Adding a `responsibility` dimension to `approval_policies` (Q8 pure
|
||||
tuple grammar). Deferred to a future ticket if a real policy
|
||||
requires it.
|
||||
- Bulk-add UI for project members. None exists today.
|
||||
- Dropping `project_teams.role` itself. Deferred to follow-up
|
||||
migration 058 after one release of soak time.
|
||||
|
||||
---
|
||||
|
||||
## §10 12 Questions — Recommendation summary
|
||||
|
||||
| # | Question | Recommendation | Locked? |
|
||||
|---|---|---|---|
|
||||
| Q1 | Where does profession live? | (a) New `paliad.users.profession` text column | open — m sign-off |
|
||||
| Q2 | Profession values | `partner \| of_counsel \| associate \| senior_pa \| pa \| paralegal` (NULL = external) | open — m sign-off |
|
||||
| Q3 | Onboarding flow | Required-on-invite, default `associate`, admin-editable | open — m sign-off |
|
||||
| Q4 | Project responsibility values | `lead \| member \| observer \| external` | open — m hinted yes |
|
||||
| Q5 | Default value | `member` | open — m hinted yes |
|
||||
| Q6 | Display | 3 columns: Name · Profession (badge) · Responsibility (inline-edit), plus existing Herkunft | open — m sign-off |
|
||||
| Q7 vs Q8 | Ladder migration | Q7 (rename to profession) WITH project-responsibility as a binary gate (`responsibility ∈ {lead, member}` opens the gate) | open — main architectural call |
|
||||
| Q9 | Backfill | Profession = highest legacy tier per user (`lead → partner`, `of_counsel → of_counsel`, …, externals → NULL); responsibility per single-row mapping (`lead → lead`, `observer → observer`, externals → `external`, others → `member`) | open — m sign-off |
|
||||
| Q10 | Down-migration | Reversible with documented best-effort data loss; `project_teams.role` kept as deprecated shadow until follow-up 058 | open — m sign-off |
|
||||
| Q11 | Team table layout | 3-column tabular (rejecting tooltip-only profession); inline-edit responsibility; profession edits live on `/admin/team` | open — m sign-off |
|
||||
| Q12 | Bulk add / invite | Profession capture on invite (default `associate`, "Extern" hides field). No bulk-add v1. Admin re-edits via `/admin/team` | open — m sign-off |
|
||||
|
||||
---
|
||||
|
||||
## §11 Coordination with sibling work
|
||||
|
||||
- **t-138 (approvals)**: shipped 2026-05-06 (commit `e2e1381`). Migration
|
||||
054 sets up the ladder; this design extends it to read from
|
||||
`users.profession` instead of `project_teams.role`. Policy grammar
|
||||
unchanged. `required_role` enum gains `partner`, drops `lead`
|
||||
(renamed in backfill).
|
||||
- **t-139 (hierarchy + derivation)**: all 3 phases shipped. Migration
|
||||
055 added `partner_unit_members.unit_role` and the
|
||||
`approval_role_from_unit_role` bridge. This design leaves the
|
||||
bridge untouched — `unit_role` values map 1:1 to the new profession
|
||||
enum (`lead → partner`, `attorney → associate`, `senior_pa →
|
||||
senior_pa`, `pa → pa`, `paralegal → paralegal`). Update the bridge's
|
||||
`lead → lead` row to `lead → partner` in migration 057.
|
||||
- **t-144 (Custom Views)**: shipped. ViewService.runApprovalRequests
|
||||
uses ApprovalService.ListPendingForApprover, which reads the new
|
||||
ladder. Inherits the change automatically.
|
||||
- **t-paliad-145 (local chat)**: parked. Not relevant.
|
||||
|
||||
No siblings are blocked by this work, and this work doesn't block any
|
||||
sibling. Independent migration, independent merge.
|
||||
|
||||
---
|
||||
|
||||
## §12 Inventor parking
|
||||
|
||||
Inventor (kepler) parks here. Awaits m's pass through the 12 questions
|
||||
in §10 + any course-correction. After m signs off, this design locks
|
||||
and a fresh coder shift can pick up the single PR. Branch:
|
||||
`mai/kepler/inventor-profession-vs`.
|
||||
|
||||
DESIGN READY FOR REVIEW.
|
||||
1250
docs/design-projects-page-2026-05-07.md
Normal file
1250
docs/design-projects-page-2026-05-07.md
Normal file
File diff suppressed because it is too large
Load Diff
469
docs/design-universal-filter-2026-05-08.md
Normal file
469
docs/design-universal-filter-2026-05-08.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Universal filter + view-mode primitive across all entity-views
|
||||
|
||||
**Issue:** m/paliad#23 (t-paliad-163)
|
||||
**Inventor:** riemann (mai/riemann/inventor-universal)
|
||||
**Date:** 2026-05-08
|
||||
**Status:** READY FOR REVIEW — no code yet, design only.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the central position
|
||||
|
||||
m's framing is exactly right: "halfway there without custom views". The Custom Views substrate (t-paliad-144) is the missing primitive — it just hasn't been lifted from "a saved-view feature on /views/{slug}" up to "the bar that every list-shaped page reads from".
|
||||
|
||||
Concrete take:
|
||||
|
||||
- **Don't invent a new schema or a new query layer.** `internal/services/filter_spec.go` + `render_spec.go` + `view_service.go` already cover every axis the issue lists, and `POST /api/views/run` and `POST /api/views/{slug}/run` already accept ad-hoc spec overrides. The substrate's own comment says it: *"Phase B will route them here; Phase A1 leaves the wiring as a no-op for those pages."* (`internal/handlers/views.go:247`). t-paliad-163 is Phase B with a UX-shaped artifact at the front.
|
||||
- **Build one frontend `<FilterBar>` component** that consumes a `FilterSpec` + `RenderSpec` + a per-surface `axes[]` declaration, owns URL/local-state, and emits diffs. Drop it on every list-shaped surface. Each system page declares a base spec (= one of the existing `SystemView` definitions) and the supported axes.
|
||||
- **"Save current filter as named view" is one button** on the bar. It POSTs the effective spec to `/api/user-views`. The custom-view editor (`/views/new`, `/views/{slug}/edit`) becomes a power-user form for the same data the bar produces; the bar is the everyday entry point.
|
||||
- **/projects stays bespoke** (locked in t-paliad-149). Source⊥Shape orthogonality breaks for projects — they don't render as cards/calendar in the events sense, and `paliad.user_card_layouts` is a different primitive (per-card facts, not filters). The bar coexists with the `<details>`-chip cluster on /projects without subsuming it.
|
||||
|
||||
The migration is one surface at a time. /inbox first (no filter today, lowest blast radius), /events last (richest filter today, the proof point that the primitive can absorb it).
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live
|
||||
|
||||
Before designing on top of CLAUDE.md / memory / the issue body, I checked the live tree:
|
||||
|
||||
- **`paliad.user_views` (056) exists.** `paliad.user_card_layouts` (061) exists. **`paliad.user_view_layouts` does NOT exist** — the issue body's reference is a typo. Real names: `paliad.user_views` is the FilterSpec/RenderSpec store; `paliad.user_card_layouts` is the per-card-facts store for /projects only. `grep -rn user_view_layouts` returns nothing.
|
||||
- **`POST /api/views/run`** takes an inline `FilterSpec` and returns `ViewRunResult{rows, inaccessible_project_ids}` without touching the DB. (`internal/handlers/views.go:248`)
|
||||
- **`POST /api/views/{slug}/run`** accepts an optional `{filter: <override>}` body that overrides the saved/system spec for one run — does not mutate storage. (`internal/handlers/views.go:282`, `runRequest` at `:238`)
|
||||
- **5 SystemViews are already code-resident** (`dashboard`, `agenda`, `events`, `inbox`, `inbox-mine`) at `internal/services/system_views.go:35`-`156`. Their slugs are reserved against user-view collisions. Each carries a canonical `FilterSpec` + `RenderSpec`.
|
||||
- **3 render-shape components exist** in `frontend/src/client/views/`: `shape-list.ts`, `shape-cards.ts`, `shape-calendar.ts`. They take `(host, rows, render)` — pure config-driven dispatch.
|
||||
- **List shape supports density (compact|comfortable), 13 known columns, and sort.** Column registry at `internal/services/render_spec.go:99`: `["date","time","title","project","actor","status","rule","event_type","location","appointment_type","approval_status","decided_by","kind"]`. Sort: `date_asc | date_desc`.
|
||||
- **`attachEventTypeMultiSelectFilter`** in `frontend/src/client/event-types.ts` is a mature listbox-panel component (search + grouped checkboxes + URL round-trip + internal `onLangChange` subscription per t-paliad-117). The pattern to copy for project + appointment-type + status panels.
|
||||
- **`renderAgendaTimeline`** in `frontend/src/client/agenda-render.ts` is the day-grouped timeline used both by `/agenda` and dashboard inline; reusable.
|
||||
- **`.entity-table` row-click contract** is the project-wide rule (CLAUDE.md "Frontend conventions"). Any list-shape table must wire row-handlers that skip clicks on inner `<a>`/`<button>` and add `entity-table--readonly` when rows don't navigate. The bar must not regress this — it doesn't, because `shape-list.ts` already emits `entity-table--readonly` on its tables.
|
||||
|
||||
---
|
||||
|
||||
## 1. The 7 list-shaped surfaces today — what they each have
|
||||
|
||||
A factual map of who has what. The underlinings are the axes the issue calls out.
|
||||
|
||||
| Surface | Filter axes today | View modes | State store |
|
||||
|---|---|---|---|
|
||||
| **/agenda** (`client/agenda.ts`, 226 LoC) | type chip (deadlines/appointments/both), range chip (7/14/30/90d), event-type multi-select | timeline only | URL `?range=&types=&event_type=` |
|
||||
| **/events** (`client/events.ts`, 1083 LoC) — also `/deadlines`, `/appointments` via 302 redirect | type chip (deadline/appointment/all), status select (8 buckets), project select (single, with `__personal__` sentinel), event-type multi (deadline-only), appointment-type select (appointment-only) | cards / list / calendar | URL `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` |
|
||||
| **/inbox** (`client/inbox.ts`, 329 LoC) — both tabs | tab (pending-mine / mine), nothing else | list only | URL `?tab=` |
|
||||
| **/projects** (`client/projects.ts` + `client/projects-cards.ts`) | search input, 6 chips (scope/status/type/has-open-deadlines), `<details>` multi-select for status + type | tree / cards / flat | sessionStorage `paliad.projects.lastView` + URL overlay |
|
||||
| **/views/{slug}** (`client/views.ts`) | none in the viewer (only saved-spec); shape switcher (list/cards/calendar) | list / cards / calendar | URL path |
|
||||
| **dashboard** (`client/dashboard.ts`, inline Agenda + Letzte Aktivität) | none | inline timeline / inline list | none |
|
||||
| **/views/new \| /views/{slug}/edit** (`client/views-editor.ts`) | full FilterSpec form (sources / scope / time / shape / list density) | n/a — author surface | n/a |
|
||||
|
||||
The pattern m sees on `/inbox?tab=mine` is the natural endpoint of seven surfaces all building filters their own way: the surface that didn't have a filter author yet is also the surface with no filter chrome at all.
|
||||
|
||||
The good news: every axis on every surface is **already nameable in the FilterSpec / RenderSpec grammar** that `internal/services/filter_spec.go` ships. There's a one-to-one mapping; nothing has to be invented at the data layer.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the universal primitive is — `<FilterBar>`
|
||||
|
||||
A single TypeScript component, mounted on a host `<div>`, parameterised by:
|
||||
|
||||
```ts
|
||||
interface FilterBarOpts {
|
||||
// Base spec — usually a SystemView's FilterSpec, fetched from /api/views/system.
|
||||
// For /views/{slug}, this is the user-view's saved filter_spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface supports. Universal axes always render;
|
||||
// per-surface axes render iff present in this list.
|
||||
axes: AxisKey[];
|
||||
|
||||
// Optional fixed predicates the surface refuses to let users tweak.
|
||||
// E.g. /inbox forces sources=[approval_request], not relaxable.
|
||||
pinned?: PartialFilterSpec;
|
||||
|
||||
// Where to write rows when filter changes. The bar runs the spec via
|
||||
// /api/views/run and hands the result back here for shape rendering.
|
||||
onResult: (res: ViewRunResult, effective: { filter: FilterSpec; render: RenderSpec }) => void;
|
||||
|
||||
// Optional URL-param namespace (defaults to the empty namespace).
|
||||
// Useful for embedding the bar twice on one page (dashboard inline)
|
||||
// without colliding ?time= / ?time2=. Phase 4 ramps this up if needed.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Optional surface key — used as the localStorage key for view-mode
|
||||
// and density preferences ("paliad.bar.<surfaceKey>.prefs").
|
||||
surfaceKey: string;
|
||||
|
||||
// Optional sidebar slot — when present, "Save as view" + "Reset" are
|
||||
// rendered. Defaults to true on every surface except dashboard inline.
|
||||
showSaveAsView?: boolean;
|
||||
}
|
||||
|
||||
type AxisKey =
|
||||
| "project" // ← universal (always rendered if axes contains it; otherwise the chip is hidden)
|
||||
| "time" // ← universal
|
||||
| "personal_only" // ← universal
|
||||
| "deadline_status" // ← per-surface (deadline source only)
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "shape" // ← view-mode (list|cards|calendar)
|
||||
| "sort" // ← per-shape
|
||||
| "density" // ← list-shape only
|
||||
| "columns"; // ← list-shape only (advanced; popover with checkboxes)
|
||||
```
|
||||
|
||||
The bar's job:
|
||||
1. On mount, parse URL params (within `urlNamespace`) and `localStorage["paliad.bar.<surfaceKey>.prefs"]`, overlay them on `baseFilter` + `baseRender`, validate, and POST `/api/views/run` with the effective spec.
|
||||
2. Render chrome — chips for booleans / single-selects, popovers for multi-selects, segmented control for view-mode. Each control is a thin wrapper over an existing pattern (chip-row, multi-anchor + multi-panel, segment-control).
|
||||
3. On any change, re-validate, sync URL, sync localStorage (for prefs only — see §3), POST the spec again, hand the result + effective spec to `onResult`. The shape host renders.
|
||||
4. Expose two trailing actions (when `showSaveAsView`): **Speichern als Sicht** and **Zurücksetzen**.
|
||||
|
||||
What the bar is NOT:
|
||||
- Not a router. Pages still own their URL.
|
||||
- Not a layout system. Cards on /projects keep the `paliad.user_card_layouts` primitive (per-card facts) — that's orthogonal to filtering.
|
||||
- Not the renderer. The bar just hands `(rows, effectiveRender)` to one of `shape-list / shape-cards / shape-calendar`.
|
||||
- Not a substitute for the dedicated views editor. That stays for power-users who want full control (predicates, custom horizons, columns).
|
||||
|
||||
---
|
||||
|
||||
## 3. The 7 brief items — taking positions
|
||||
|
||||
### 3.1 Filter axes: which are universal, which are per-surface, how does the bar declare its supported axes?
|
||||
|
||||
**Universal** — render always when `axes` contains them (and the surface's pinned spec doesn't rule them out):
|
||||
- `project` — single-select with the existing `<select>` (Alle / Nur persönliche / each project, ltree-indented). On surfaces where multi-project would help later (system-wide views), the same control upgrades to a multi-select listbox-panel by adding a `multi: true` flag — postpone to phase C, single-select covers every surface today.
|
||||
- `time` — segmented chip group (`Heute · 7T · 30T · 90T · Alles · Anpassen`). Maps to `time.horizon`. "Anpassen" pops a date-range pair (`time.horizon = "custom"` + from/to). On /inbox the chip group reads "Heute · 7T · 30T · Alles" since approval queues are usually now-shaped — but the same control.
|
||||
- `personal_only` — boolean chip ("Nur eigene"). Active when `scope.personal_only=true`. Hidden when source set excludes deadline AND appointment (others don't honour personal_only).
|
||||
|
||||
**Per-surface** — declared in `axes`, controlled by which sources the spec uses:
|
||||
- `deadline_status` (chip cluster: "Offen · Überfällig · Erledigt · Alle") — only when `sources` includes deadline.
|
||||
- `deadline_event_type` (multi-select listbox-panel, reuses `attachEventTypeMultiSelectFilter`) — only when sources includes deadline.
|
||||
- `appointment_type` (single-select for now: hearing/meeting/consultation/deadline_hearing/Alle) — only when sources includes appointment.
|
||||
- `approval_viewer_role` (segmented chips: "Zur Genehmigung · Eigene Anfragen · Alle sichtbaren") — only when sources includes approval_request. This subsumes the /inbox tab.
|
||||
- `approval_status` (chip cluster: "Wartend · Entschieden · Alle") — only when sources includes approval_request.
|
||||
- `approval_entity_type` (chip pair: "Fristen · Termine") — only when sources includes approval_request.
|
||||
- `project_event_kind` (multi-select listbox-panel; the 13 `KnownProjectEventKinds`) — only when sources includes project_event. Powers the dashboard "Letzte Aktivität" filter.
|
||||
|
||||
**View-mode + per-shape** — declared in `axes`, but special:
|
||||
- `shape` — segmented chips (list/cards/calendar). Always rendered when `axes` contains `shape`; available shapes derived from `baseRender` + the surface's whitelist. The bar emits a transient render override (mirrors how `client/views.ts:171` does shape-switching today: it doesn't rerun, just re-renders).
|
||||
- `sort` — single-select (`date_asc | date_desc`).
|
||||
- `density` — segmented chip pair (Komfortabel / Kompakt) — list shape only, hidden otherwise.
|
||||
- `columns` — popover with checkbox list of `KnownListColumns` — list shape only, advanced opt-in.
|
||||
|
||||
**How the surface declares its axes:** an array. No higher-order component, no slot composition. Plain config. The bar's render is a switch over each axis key:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
baseFilter: agendaSystemView.filter,
|
||||
baseRender: agendaSystemView.render,
|
||||
axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"],
|
||||
surfaceKey: "agenda",
|
||||
onResult: ({rows, inaccessible_project_ids}, effective) => { ... },
|
||||
});
|
||||
```
|
||||
|
||||
Slot composition was considered. It's overkill — every existing chrome pattern paliad uses (chip cluster, multi-anchor popover, segmented control, `<select>`) is already in `frontend/src/styles/global.css`; there's nothing to plug or override. A flat axis-config keeps the bar a 600-LoC component, not a framework.
|
||||
|
||||
### 3.2 State model: URL vs in-memory vs hybrid
|
||||
|
||||
**Hybrid**, with a sharp split:
|
||||
|
||||
- **URL is canonical** for everything that affects which rows you see. That means: project (`?project=`), sources (`?sources=`), time (`?time=` for horizon, `?from=&to=` for custom), personal-only, every per-source predicate (`?deadline_status=`, `?event_type=`, `?appointment_type=`, `?approval_role=`, `?approval_status=`, `?approval_entity_type=`, `?project_event_kind=`), shape (`?shape=`), sort (`?sort=`). Bookmarkable, shareable, refresh-survives, deep-linkable from the dashboard or /inbox bell.
|
||||
- **localStorage holds preferences** that don't change rows: density (`?density=` is also a URL param when explicitly chosen, but absence falls through to localStorage default), default columns per surface (advanced opt-in), default shape per surface (only when the user has overridden the SystemView's default — first visit uses base). Keyed `paliad.bar.<surfaceKey>.prefs`. Mirrors the spirit of /projects' sessionStorage `paliad.projects.lastView` (t-paliad-149 Q1 lock-in) but at the right scope: the "what I prefer" sticks per surface, the "what this URL is showing" stays in the URL.
|
||||
- **No sessionStorage.** /projects' use was justified by tab restoration; for the bar, every interesting bit is in the URL (so back/forward + refresh + share both work). Adding a third tier would create the worst-of-three: state in URL ∪ session ∪ local, three places to look when something's off.
|
||||
|
||||
URL parameter names are stable and short. The bar exports a tiny URL codec (`encodeBarParams(filter, render) → URLSearchParams` and inverse) so the same params work whether the bar is on /agenda, /inbox, /events, or /views/{slug}.
|
||||
|
||||
The migration from /events' bespoke `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` to the bar's params is straightforward: each old param maps to a new one (or stays, when names already match — `?project_id`, `?personal_only`, `?event_type` are unchanged; `?type` becomes `?sources`; `?view` becomes `?shape`; `?status` and `?type_filter` become per-surface predicates). Server middleware on the legacy /events handler can rewrite old → new params for one release so existing bookmarks don't 404.
|
||||
|
||||
### 3.3 View-mode switcher — universal or per-surface? Sort-state ownership? Density?
|
||||
|
||||
**Universal.** The bar always owns the segmented `shape` control. The surface declares which shapes it whitelists (e.g. /inbox might whitelist `["list"]` and hide the switcher; /agenda might whitelist `["cards", "list", "calendar"]`). When the whitelist has only one entry the bar suppresses the chip; when ≥2 it renders.
|
||||
|
||||
**Sort lives in the bar's `RenderSpec.list.sort` / `cards.sort`.** Already exists in the schema. The list-shape table renderer is currently sort-by-config-only; promoting `<th>` clicks to update `RenderSpec.list.sort` is a one-line callback in the bar (`onListHeaderSort`) → server-side re-sort isn't needed because `shape-list.ts:16` already sorts in JS. **Sortable column headers become a list-shape feature owned by the bar**, not a per-surface concern.
|
||||
|
||||
**Density** is a list-shape config (`comfortable | compact`). The bar exposes the pair as a chip; `shape-list.ts` already supports both. Density on /inbox today is implicitly comfortable; toggling it to compact gives the user the activity-feed look on the inbox surface for free, which is the kind of small win the brief calls out.
|
||||
|
||||
**Multi-column sort** is out-of-scope for v1 — `shape-list.ts:16` does single-column sort, which matches every surface today. Add when a user asks.
|
||||
|
||||
### 3.4 Composability — drop-in API without forcing existing pages to refactor
|
||||
|
||||
The bar mounts onto an empty `<div>`. The surface's TSX changes are:
|
||||
- Replace the per-page filter chrome (chip cluster, selects, popovers, view-mode segment) with `<div id="filter-bar"></div>`.
|
||||
- Replace the per-page result rendering with `<div id="filter-bar-results"></div>`.
|
||||
- The page's `client/<surface>.ts` shrinks to: read `__PALIAD_<SURFACE>__` initial payload (or skip), call `mountFilterBar(host, opts)`, write `onResult` to dispatch into the matching shape component (already exist).
|
||||
|
||||
That's it. The page surface is reduced to ~50 LoC of orchestration around the bar; the bulk of `events.ts` (1083 LoC) drops to a baseline of ≈80 LoC after Phase 3 because the per-axis filter state, the project select populator, the language-hot-swap, the URL-sync, the type-visibility logic, the appointment-type filter logic, the calendar month-paging, and the cards-vs-list-vs-calendar dispatch all migrate into shared components: the bar (filter axes, view-mode, URL, language hot-swap), `shape-list.ts` (table), `shape-cards.ts` (cards), `shape-calendar.ts` (month grid).
|
||||
|
||||
The bar **does not own row interaction**. Row click → detail page is already a per-shape concern (`shape-list.ts` emits `entity-table--readonly`; the bar doesn't override that). Lifecycle actions (complete/reopen/approve/reject) are also per-shape — `shape-list.ts` will need a small extension to emit clickable-row tables on /events (so the existing complete-checkbox + reopen flow keeps working). That extension is one new render flag in `RenderSpec.list.row_action: "navigate" | "approve" | "complete-toggle" | "none"`, defaulting to navigate. Honest scope: this is a small `RenderSpec` schema bump (new optional field), not an axis change.
|
||||
|
||||
### 3.5 Reuse with the existing /views layout-spec — does the universal bar inherit, or does the spec become a special case of saved bar state?
|
||||
|
||||
**The latter.** m's hint ("halfway there without custom views") points at exactly this.
|
||||
|
||||
A **Custom View is the persisted form of a bar state.** When the user clicks "Speichern als Sicht" on /agenda, the bar gathers the effective `FilterSpec` + `RenderSpec`, prompts for name + slug + icon + show-count (a small modal — one form, four fields, mirroring `views-editor.ts`'s collectForm), and POSTs `/api/user-views`. The user is then redirected to `/views/{slug}` (or stays in place with a confirmation toast — see §3.7).
|
||||
|
||||
Conversely, **a SystemView is a code-resident bar state.** The bar already knows how to load one (`/api/views/system` → match slug). The "system pages" become surfaces whose default state happens to live in code instead of in `paliad.user_views`.
|
||||
|
||||
Implementation consequence:
|
||||
- `views-editor.ts` keeps existing for power users who want to edit predicates that the bar doesn't expose (e.g. pinning a `time.field = "created_at"` for an "audit-trail" view). The editor and the bar produce identical `FilterSpec` + `RenderSpec` JSON; they're alternate authoring UX.
|
||||
- `views.ts` (the `/views/{slug}` viewer) gains the bar above its rows. The bar renders with the saved spec as its base; the user can tweak axes (e.g. narrow the time horizon for a quick glance) — those tweaks are URL-overlays and don't mutate the saved spec until the user clicks "Aktualisieren" (a new affordance). This satisfies the brief's "halfway there" hint: today /views/{slug} renders a saved spec **statically**; with the bar, it becomes interactive without losing the saved-state semantics.
|
||||
|
||||
### 3.6 Migration path — phase one surface at a time, identify the hardest
|
||||
|
||||
The bar is shippable on one surface in one PR. Then each subsequent surface is its own small PR.
|
||||
|
||||
**Phase 1 — /inbox (the cold start).** Lowest blast radius: today /inbox has no filter chrome, only tabs. Replace tabs with the `approval_viewer_role` axis (the bar collapses two tabs into one chip cluster). Drop the bar with `axes: ["time", "approval_status", "approval_entity_type", "approval_viewer_role", "shape", "density", "sort"]`. Pin `sources: [approval_request]`. Density toggle gives the user a stream view m's "looks really bad" was diagnosing. URL contract: keep `?tab=` redirecting to `?approval_role=` for one release.
|
||||
|
||||
**Phase 2 — /agenda.** Already filter-shaped and the most readable orchestrator (226 LoC). Bar replaces the chip cluster + range chip + event-type popover. `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"]`. Default: shape="cards" (matching today's timeline default). The dashboard inline Agenda gets a stripped-down bar with `axes: ["time", "deadline_event_type"]` and `urlNamespace: "agenda"` (so the page-level bar on the dashboard doesn't collide with anything else if the dashboard adds another bar later for "Letzte Aktivität").
|
||||
|
||||
**Phase 3 — /events (the proof point).** Most complex filter today: type chip + status select + project select + personal-only + event-type multi + appointment-type select + cards/list/calendar. Every one of these axes is already nameable in FilterSpec/RenderSpec (verified §1). `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort", "density"]`. The 5-card summary above the table (Heute / Diese Woche / Nächste Woche / Später / Überfällig) becomes a bar-driven facet: clicking a card sets `time.horizon` (or for "Überfällig", a special `deadline_status: ["overdue"]` predicate). Identifying /events as the hardest surface up front means the primitive's axis registry has to be wide enough on day 1; the design above already names every needed axis, so Phase 1's primitive is forward-compatible.
|
||||
|
||||
**Phase 4 — dashboard inline lists (Agenda + Letzte Aktivität).** The dashboard composes two tiny bars: one for Agenda (cards/list, narrow time horizon, no save-as-view), one for Letzte Aktivität (project_event source, density=compact, no save-as-view). Both use `urlNamespace` to keep params tidy.
|
||||
|
||||
**Phase 5 — /views/{slug}.** Add the bar above the rows. Saved spec → bar's base; URL overlays are transient until "Aktualisieren" persists them. The custom-view editor (`/views/new`, `/views/{slug}/edit`) stays for power users; "Speichern als Sicht" from the bar is the everyday path.
|
||||
|
||||
**Out of phasing:** /projects stays bespoke. The bar coexists on the page only if a future task adds it — today the chip cluster + tree/cards/flat segment are doing fine, and Source⊥Shape orthogonality breaks for projects (no ProjectSource in the substrate; no TreeShape in the substrate). t-paliad-149's locked-in choice stands.
|
||||
|
||||
**Hardest surface, identified:** /events. Phase 3 is the proof point. By designing the bar's axis registry against /events on day 1 (not retrofitting), Phase 1 (/inbox) and Phase 2 (/agenda) ship without redesign churn.
|
||||
|
||||
### 3.7 "Save current filter as named view" — making it trivial
|
||||
|
||||
The bar's trailing action is a single button: **Speichern als Sicht**. Click → small modal:
|
||||
|
||||
```
|
||||
┌─ Sicht speichern ─────────────────────┐
|
||||
│ Name [_________________] │
|
||||
│ Slug [_________________] (opt) │
|
||||
│ Icon [▼ Auswählen ] │
|
||||
│ □ Anzahl in der Sidebar zeigen │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Speichern ] │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
If slug is empty, derive from name (kebab-case) and validate against the regex + reserved-slug list client-side (mirrors `views-editor.ts:179`). On 409 (slug taken), show inline error and let the user adjust. On success, two affordances:
|
||||
- A toast "Als Sicht 'Heute überfällig' gespeichert. Zur Sicht wechseln?" with a link to `/views/{slug}`.
|
||||
- The new view automatically appears in the **Meine Sichten** sidebar group (t-paliad-144) on next page load (or sooner, if the bar emits a window event the sidebar listens to).
|
||||
|
||||
This means: every list-shaped surface gets "save current filter as named view" for free. No per-surface plumbing.
|
||||
|
||||
**"Aktualisieren" on /views/{slug}** is the symmetric write-back: when the user is viewing a saved view and tweaks the bar, a "Aktualisieren" button appears next to "Speichern als Sicht". Click → PATCH `/api/user-views/{id}` with the effective spec. Confirmation toast.
|
||||
|
||||
**"Zurücksetzen"** clears the URL overlay and re-renders with the base spec only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Two harder questions worth surfacing now
|
||||
|
||||
### 4.1 The chip-vs-popover-vs-select tension
|
||||
|
||||
paliad has three patterns for "pick from a set" today:
|
||||
|
||||
- **Chip cluster** (e.g. /agenda type chip, /projects scope chip) — best for 2–4 mutually exclusive options. Always-visible, click-fast.
|
||||
- **`<select>`** (e.g. /events status, project, appointment-type) — best for 5–30 single-select options, especially when the option list is dynamic (project list grows).
|
||||
- **Listbox-panel popover** (e.g. event-type multi, /projects status/type `<details>`) — best for multi-select or for >30 options with search.
|
||||
|
||||
The bar must use the right pattern per axis to feel native, not regress one surface in service of another. My picks:
|
||||
|
||||
| Axis | Pattern | Why |
|
||||
|---|---|---|
|
||||
| project (single) | `<select>` | dynamic list; option count grows with the firm |
|
||||
| time | chip cluster + "Anpassen" overflow | 5 mutually exclusive presets cover 95% of usage |
|
||||
| personal_only | single chip | binary |
|
||||
| sources (when `axes` exposes it) | listbox-panel multi | 4 options but multi-select |
|
||||
| deadline_status | chip cluster | 4 options, mutually exclusive |
|
||||
| deadline_event_type | listbox-panel multi | 40+ options, search + grouped checkboxes (reuses event-types.ts pattern) |
|
||||
| appointment_type | chip cluster (4 + Alle) | small mutually-exclusive set |
|
||||
| approval_viewer_role | chip cluster | 3 mutually exclusive options |
|
||||
| approval_status | chip cluster | 4 options |
|
||||
| approval_entity_type | chip cluster | 2 options |
|
||||
| project_event_kind | listbox-panel multi | 13 options, multi-select |
|
||||
| shape | segmented control | 1-of-N, special UX (icon-only buttons) |
|
||||
| sort | `<select>` (small) | 2 options today, room for `title_asc/desc` later |
|
||||
| density | segmented control | binary, icon-shaped |
|
||||
|
||||
The point: the bar isn't one widget, it's a thin shell that delegates each axis to the right existing control. CSS reuse: `.agenda-chip` / `.events-view-btn` / `.akten-multi-trigger` / `.multi-anchor` / `.multi-panel` all stay; the bar just composes them.
|
||||
|
||||
### 4.2 Empty-state UX when an axis is invalid for the current sources
|
||||
|
||||
If the user clears all sources, every per-source axis becomes meaningless. Two options:
|
||||
- **Hide invalid axes.** Cleanest. Bar reacts to source changes by collapsing dependent chips. Risk: feels jumpy.
|
||||
- **Disable + tooltip.** Less jumpy but visually noisier.
|
||||
|
||||
Recommend **hide**, with one twist: the bar persists hidden-axis state in the URL anyway, so toggling sources back on restores the user's prior filter. This matches /events' existing behaviour (when type=appointment, event-type panel is hidden but its state persists in `?event_type=`).
|
||||
|
||||
---
|
||||
|
||||
## 5. RenderSpec extensions — one schema bump
|
||||
|
||||
The bar exposes capabilities that are already in `RenderSpec` (shape, sort, density, columns) plus one new field:
|
||||
|
||||
```go
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
RowAction ListRowAction `json:"row_action,omitempty"` // NEW — "navigate" (default) | "complete_toggle" | "approve" | "none"
|
||||
}
|
||||
```
|
||||
|
||||
`RowAction` lets `shape-list.ts` know whether to wire an `entity-table--readonly` or to attach the existing checkbox / reopen / approve / reject buttons. Default `navigate` keeps the contract stable; system pages explicitly set `complete_toggle` (events list) and `approve` (inbox list).
|
||||
|
||||
This is the only schema change. Every other axis is already in the spec.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard requirements from the brief — addressed
|
||||
|
||||
- **`.entity-table` row-click contract.** The bar's list-shape table is rendered by `shape-list.ts:80` which already emits `entity-table--readonly`. When `RowAction="navigate"` the bar adds a row-handler that does `window.location.href = detailRoute(row)` and skips clicks on inner `<a>`/`<button>` (mirrors the existing `events.ts:wireRowHandlers` pattern). Whole-card / whole-row click → JS row-handler, never `::before` overlays (CLAUDE.md frontend conventions, t-paliad-102).
|
||||
- **No hour estimates.** Throughout this design.
|
||||
- **DE+EN bilingual.** Every new label gets a key under `views.bar.*` (single new namespace; ~25 keys for axes + ~10 for save modal + ~10 for empty/loading/error states). Keys are added to `frontend/src/client/i18n.ts`'s registry at the appropriate phase.
|
||||
- **Mobile.** The bar collapses to a single horizontal scroll row on `≤768px` (mirrors `.frist-summary-cards` mobile pattern). The "Speichern als Sicht" + "Zurücksetzen" actions move into a `<details>` "Mehr" affordance on mobile to keep the scrollable strip clean. Re-imagining mobile-list-mode is out of scope per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 7. Trade-offs — the honest list
|
||||
|
||||
### What this design gains
|
||||
1. **One filter chrome across all list-shaped surfaces.** Users learn one bar, every surface respects it. Discoverability for "save as view" jumps from one surface (/views/new editor) to seven.
|
||||
2. **System pages become substrate clients.** `/api/views/run` (already shipped) becomes the canonical event-fetching path. Phase B from t-paliad-144 design lands.
|
||||
3. **`events.ts` shrinks ~10×.** Most of its 1083 lines are filter chrome + URL sync + view-mode dispatch — all now shared.
|
||||
4. **Save-as-view is universal.** Today only /views/new + /views/{slug}/edit can author saved views; after the migration, every page can.
|
||||
5. **/inbox gains filters and sort and density** as a free side effect of the migration — directly addressing m's "looks really bad" diagnosis.
|
||||
6. **Sortable column headers** become a substrate feature (small bar callback that updates `RenderSpec.list.sort`).
|
||||
7. **The schema barely moves** — one new optional field on `ListConfig`. Migrations not needed.
|
||||
|
||||
### What this design risks
|
||||
1. **One component holding many axes is at risk of bloat.** Mitigation: the bar is a flat axis-config (no slot composition, no HOC). 600 LoC ceiling enforced by the per-axis switch pattern. CSS reuse keeps the visual surface small.
|
||||
2. **The /events migration is the largest single PR.** 1083 LoC client → ≈100 LoC + ≈250 LoC of bar config + per-shape extensions. A regression on the 5-card summary or the deadline complete/reopen flow would be visible. Mitigation: Phase 3 is gated behind Phase 1 (/inbox) and Phase 2 (/agenda) shipping cleanly, and the design lands the `RowAction` schema bump in Phase 1 so `complete_toggle` is wired before /events arrives.
|
||||
3. **URL overlay on /views/{slug} creates two states.** Saved spec ≠ effective spec when the user has tweaked the bar. The "Aktualisieren" / "Speichern als Sicht" actions resolve which becomes canonical, but a user who navigates away with unsaved tweaks loses them. Mitigation: a `?dirty=1` URL marker + a small toast on first tweak ("Änderungen sind nicht gespeichert").
|
||||
4. **Two filter chromes coexist on /projects.** The bar doesn't subsume the chip cluster (Source⊥Shape break). Future visual unification would standardise the chip pattern between the two — out of scope here.
|
||||
5. **Hidden-axis URL state.** Persisting `?event_type=` even when sources excludes deadline can confuse a user reading their URL. Acceptable: matches /events' current behaviour and is reversible by toggling the source back. The alternative (pruning URL params on source change) loses the user's prior state on a quick re-toggle.
|
||||
6. **i18n hot-swap correctness.** Every dynamic populator must subscribe to `onLangChange` (the t-paliad-117 lesson). The bar handles this once internally for every axis; surfaces don't need to wire it per-page.
|
||||
7. **Default per-surface defaults can drift from SystemView.** The bar reads `localStorage` for prefs (e.g. preferred shape on /agenda). If a user toggles a pref then a SystemView default changes, the user's pref wins. Mitigation: `localStorage` only stores explicit overrides, not the base value, so changes to the SystemView's base flow through for users who haven't overridden.
|
||||
8. **Two storage primitives ("user_views" + "user_card_layouts") could be confusing.** Names are similar; they store different things. Mitigation: documentation. The bar only ever reads/writes `paliad.user_views`. /projects' card-layout is a separate, narrow concern that stays bespoke.
|
||||
|
||||
### Reversibility
|
||||
- The bar is purely additive. Phase 1 doesn't touch /agenda or /events. If after Phase 1 the bar feels wrong, /inbox can revert to its prior chrome by reverting one PR. Phase 2 only ships after Phase 1 holds.
|
||||
- The new `RenderSpec.list.row_action` field is optional with a `navigate` default; existing rows continue to render correctly.
|
||||
- The URL contract is preserved for /events for one release via a thin redirect middleware that maps old → new params; bookmarks don't 404.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m before lock-in
|
||||
|
||||
These are decisions where my recommendation might be challenged:
|
||||
|
||||
**Q1. State model: full URL-canonical, or do we accept localStorage for shape/density preferences?** I recommend hybrid: URL for filter axes, localStorage for shape + density prefs (per-surface). Keeps shareable URLs honest while letting "I always want compact density on /inbox" persist across sessions.
|
||||
|
||||
**Q2. Save-as-view modal vs slide-out vs inline.** I recommend modal — minimal surface, four fields, blocks the page. Alternatives: a slide-out (less interruption, more work) or an inline expansion of the "Speichern" button (cramped on mobile). Modal lines up with existing `<dialog>` usage on /admin.
|
||||
|
||||
**Q3. /events 5-card summary — keep, or fold into the bar?** I recommend keep (above the bar, unchanged). The cards encode urgency at a glance; collapsing them into the bar's `time` chip would lose the "9 / 3 / 2 / 5 / Überfällig 1" density. Clicking a card still updates the bar's time horizon (existing behaviour preserved).
|
||||
|
||||
**Q4. Tabs on /inbox — collapse into the `approval_viewer_role` chip cluster, or keep tabs as visual chrome above the bar?** I recommend collapse — one fewer place for state, the chip cluster is exactly the right control for 3 mutually exclusive options. Counter-argument: tabs are a strong visual hint of "two pages with the same shape". My counter-counter: the bar's chips are the same hint, less mid-air.
|
||||
|
||||
**Q5. URL parameter naming.** I recommend short, namespaced names: `?time=`, `?sources=`, `?project=`, `?personal=`, per-source predicate names (`?d_status=` for deadline.status, `?a_role=` for approval_request.viewer_role, `?pe_kind=` for project_event.event_types). Cargo-friendly to long names like `?deadline_status=` if m prefers — same axis, same wire format.
|
||||
|
||||
**Q6. "Speichern als Sicht" on the dashboard inline bars — show or hide?** I recommend hide. The dashboard composes two tiny bars; saving a sub-bar's spec as a custom view would feel disjoint from the dashboard concept. Power users can craft custom views via /views/new instead.
|
||||
|
||||
**Q7. Migration: do we keep `?type=` redirecting on /events for one release, or hard-cut?** I recommend keep for one release (small middleware in `internal/handlers/events_pages.go`) so existing bookmarks (Sidebar, internal docs, the /events sidebar links at `events.ts:838`) keep working through Phase 3.
|
||||
|
||||
**Q8. /views/{slug} — should the URL overlay tweak persist in localStorage as a "draft" until the user resets or saves?** I recommend no — URL is the only state, and a tweak that disappears on reload matches user expectation. The `?dirty=1` toast is enough. Alternative: a per-view-id `paliad.bar.view-{id}.draft` localStorage key that re-applies on re-visit — more powerful, more surprising.
|
||||
|
||||
**Q9. Sortable column headers — list shape only, or also rule for cards/calendar in a future phase?** I recommend list-shape only for v1. Cards and calendar have their own ordering semantics (group_by + within-group sort); promoting headers would over-complicate.
|
||||
|
||||
**Q10. Bar embedding twice on dashboard — `urlNamespace` worth the complexity, or single namespace and accept that dashboard's two bars share `?time=`?** I recommend `urlNamespace` for dashboard only (e.g. `?agenda_time=` and `?activity_time=`). Costs ~10 LoC, keeps two bars from colliding.
|
||||
|
||||
**Q11. Multi-project select — phase C, or fold into Phase 2?** I recommend phase C. Single-project covers every surface today; multi-project unlocks "all my Düsseldorf cases this week" type queries but no current page asks for it. Save complexity until a user does.
|
||||
|
||||
**Q12. EventTypeMultiSelect today supports `none` ("Ohne Typ") — keep or drop?** I recommend keep. The bar's deadline_event_type axis just wraps `attachEventTypeMultiSelectFilter`, so `none` works as-is. Honestly nothing to design here.
|
||||
|
||||
---
|
||||
|
||||
## 9. Scope boundaries (in + out)
|
||||
|
||||
### In scope
|
||||
- New `<FilterBar>` component + axis registry + URL codec.
|
||||
- One `RenderSpec.list.row_action` field with validator update.
|
||||
- Phase 1: /inbox surface + tests.
|
||||
- Documentation + i18n keys for the bar.
|
||||
- Phase 2..5 named in the migration path with clear gates between them — but each is its own PR and not part of "the inventor design has shipped" definition-of-done.
|
||||
|
||||
### Out of scope (per the brief + my reading)
|
||||
- New entity surfaces. Only the 7 named surfaces.
|
||||
- Backend SQL migrations beyond the one optional `RenderSpec.list.row_action` field. The bar runs through `/api/views/run` which already exists.
|
||||
- /projects redesign — t-paliad-149 stands.
|
||||
- Mobile-list-mode reimagining — separate workstream.
|
||||
- Multi-project selection — phase C, not v1.
|
||||
- Multi-column sort — when a user asks.
|
||||
- Internationalisation beyond DE + EN.
|
||||
|
||||
---
|
||||
|
||||
## 10. Files implementer will touch (Phase 1: /inbox)
|
||||
|
||||
To make the scope concrete:
|
||||
|
||||
**New:**
|
||||
- `frontend/src/components/FilterBar.tsx` — TSX wrapper with the host divs.
|
||||
- `frontend/src/client/filter-bar/index.ts` — `mountFilterBar` entry point.
|
||||
- `frontend/src/client/filter-bar/axes.ts` — per-axis render functions (one per `AxisKey`).
|
||||
- `frontend/src/client/filter-bar/url-codec.ts` — `encode/decode/diffWithBase`.
|
||||
- `frontend/src/client/filter-bar/save-modal.ts` — the "Speichern als Sicht" modal.
|
||||
- `frontend/src/client/filter-bar/types.ts` — `FilterBarOpts`, `AxisKey`.
|
||||
- `frontend/src/client/filter-bar/i18n.ts` — namespace registry helper.
|
||||
|
||||
**Modified (Phase 1):**
|
||||
- `frontend/src/inbox.tsx` — replace tab row with `<div id="filter-bar">` + `<div id="filter-bar-results">`.
|
||||
- `frontend/src/client/inbox.ts` — shrink to `mountFilterBar(host, {baseFilter: inboxSystemView, axes: [...], onResult: renderListShape})`.
|
||||
- `internal/handlers/inbox.go` — add `?approval_role=` redirect from old `?tab=` for one release. (The actual rows continue to come from `/api/views/run` via the bar.)
|
||||
- `internal/services/render_spec.go` — add `RowAction` field + validator + `KnownRowActions = ["navigate", "complete_toggle", "approve", "none"]`.
|
||||
- `frontend/src/client/views/types.ts` — TS mirror of the new `RowAction` field.
|
||||
- `frontend/src/client/views/shape-list.ts` — honour `RowAction` (navigate is the existing default; `approve` mounts approve/reject buttons; `complete_toggle` mounts the checkbox).
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — ~30 new keys under `views.bar.*`.
|
||||
- `frontend/src/styles/global.css` — bar layout + mobile rules. Reuses existing `.agenda-chip`, `.akten-multi-*`, `.frist-summary-card`, `.multi-anchor`/`.multi-panel`, `.events-view-btn` styles.
|
||||
|
||||
**Tests (Phase 1):**
|
||||
- `internal/services/render_spec_test.go` — add cases for `RowAction` validator (8 cases: each enum value + invalid + omitted + …).
|
||||
- `frontend/src/client/filter-bar/url-codec.test.ts` — round-trip encode/decode for every `AxisKey`.
|
||||
- `internal/handlers/inbox_redirect_test.go` — old-tab → new-axis redirect.
|
||||
|
||||
**Phase 2..5 file lists** are not enumerated here — each is a separate PR with its own surface refactor and follows the same shape (replace per-page chrome + URL sync, mount the bar, hand `onResult` to the existing shape components).
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended implementer
|
||||
|
||||
**Pattern-fluent Sonnet coder** is the right fit. Substrate is well-trodden:
|
||||
- Custom Views client + render shapes already exist (t-paliad-144).
|
||||
- Multi-select listbox-panel already exists (`event-types.ts`).
|
||||
- Chip-row pattern exists on `/agenda`, `/projects`, `/events`.
|
||||
- Save modal pattern exists on `/views/new` (`views-editor.ts`).
|
||||
- URL-sync pattern exists on every system page.
|
||||
|
||||
The first PR (Phase 1: /inbox + bar scaffolding + `RowAction` schema bump) is contained and reviewable in one window. Subsequent phases are smaller — they're "swap in the bar and delete page-local code".
|
||||
|
||||
I am happy to be the coder if m wants minimum context-switch — riemann has the live model of every piece of this design. Equally happy to hand off to a fresh Sonnet coder with this doc as the brief; the doc is intended to be self-contained for that path.
|
||||
|
||||
The head decides.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phasing summary (no estimates, just order)
|
||||
|
||||
1. /inbox migration + `<FilterBar>` scaffolding + `RowAction` schema bump.
|
||||
2. /agenda migration.
|
||||
3. /events migration (proof point — most complex filter today, biggest LoC delta).
|
||||
4. Dashboard inline bars (Agenda + Letzte Aktivität).
|
||||
5. /views/{slug} bar overlay + "Aktualisieren" affordance.
|
||||
|
||||
Each phase is its own PR. Phases must merge in order; m's merge gate at every step.
|
||||
|
||||
---
|
||||
|
||||
## 13. Why this is worth an inventor
|
||||
|
||||
m's last line in the brainstorm: *"worth an inventor?"*. Yes — and the reason is exactly what the design doc surfaces: the substrate already exists, the schema's right, the run endpoints are shipped, and 5 SystemViews are already declared. A coder coming in cold would either (a) not realise the substrate is there and reinvent it, or (b) realise and underestimate how much per-surface chrome can collapse into one bar. The inventor's job here was to read what's there, name the bar primitive, identify /events as the proof point, propose the one schema bump (`RowAction`) that makes /inbox shippable in Phase 1, and resist designing a layout-spec system that's already covered by `RenderSpec`.
|
||||
|
||||
Stop. DESIGN READY FOR REVIEW.
|
||||
@@ -29,12 +29,19 @@ import { renderOnboarding } from "./src/onboarding";
|
||||
import { renderChangelog } from "./src/changelog";
|
||||
import { renderTeam } from "./src/team";
|
||||
import { renderAdmin } from "./src/admin";
|
||||
import { renderInbox } from "./src/inbox";
|
||||
import { renderViews } from "./src/views";
|
||||
import { renderViewsEditor } from "./src/views-editor";
|
||||
import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
@@ -248,6 +255,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/settings.ts"),
|
||||
join(import.meta.dir, "src/client/dashboard.ts"),
|
||||
join(import.meta.dir, "src/client/agenda.ts"),
|
||||
join(import.meta.dir, "src/client/inbox.ts"),
|
||||
join(import.meta.dir, "src/client/views.ts"),
|
||||
join(import.meta.dir, "src/client/views-editor.ts"),
|
||||
join(import.meta.dir, "src/client/onboarding.ts"),
|
||||
join(import.meta.dir, "src/client/changelog.ts"),
|
||||
join(import.meta.dir, "src/client/team.ts"),
|
||||
@@ -258,6 +268,15 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
// bundle ships once per deploy and clients with a hot SW cache
|
||||
// skip the re-fetch.
|
||||
join(import.meta.dir, "src/client/paliadin-widget.ts"),
|
||||
join(import.meta.dir, "src/client/admin-paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
@@ -360,6 +379,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "settings.html"), renderSettings());
|
||||
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
|
||||
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
|
||||
await Bun.write(join(DIST, "inbox.html"), renderInbox());
|
||||
await Bun.write(join(DIST, "views.html"), renderViews());
|
||||
await Bun.write(join(DIST, "views-editor.html"), renderViewsEditor());
|
||||
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
|
||||
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
|
||||
await Bun.write(join(DIST, "team.html"), renderTeam());
|
||||
@@ -370,6 +392,10 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
|
||||
137
frontend/src/admin-approval-policies.tsx
Normal file
137
frontend/src/admin-approval-policies.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-154 — admin approval-policy authoring page. Single page with
|
||||
// two sections:
|
||||
//
|
||||
// 1. Partner-Unit-Standards: list of partner_units, each expandable into
|
||||
// its 8-cell matrix (deadline + appointment × create / update /
|
||||
// complete / delete). Edits hit /api/admin/partner-units/{id}/...
|
||||
//
|
||||
// 2. Projekt-spezifisch: project-tree picker → 8-cell matrix for the
|
||||
// selected project, showing the EFFECTIVE policy per cell with an
|
||||
// attribution chip (Projekt / Geerbt / Standard). Edits hit
|
||||
// /api/projects/{id}/approval-policies/{entity}/{lifecycle}.
|
||||
//
|
||||
// Mobile shape: the matrix grid collapses to two stacked sections (Fristen,
|
||||
// Termine) below 700px — driven by CSS, not by JS.
|
||||
|
||||
export function renderAdminApprovalPolicies(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.approval_policies.title">Genehmigungspflichten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/approval-policies" />
|
||||
<BottomNav currentPath="/admin/approval-policies" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="admin.approval_policies.heading">Genehmigungspflichten</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.approval_policies.subtitle">
|
||||
4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="ap-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
{/* ============================================================
|
||||
Section 1: Partner-Unit-Standards.
|
||||
============================================================ */}
|
||||
<h2 className="section-heading" data-i18n="admin.approval_policies.section.units">
|
||||
Partner-Unit-Standards
|
||||
</h2>
|
||||
<p className="form-hint" data-i18n="admin.approval_policies.section.units.hint">
|
||||
Standardregeln, die jedes Projekt erbt, das einer Partner Unit zugeordnet ist.
|
||||
Bei mehreren Partner Units gewinnt die strengste Regel.
|
||||
</p>
|
||||
<div className="ap-units-list" id="ap-units-list">
|
||||
<div className="ap-loading" data-i18n="admin.approval_policies.loading">Lädt …</div>
|
||||
</div>
|
||||
|
||||
{/* ============================================================
|
||||
Section 2: Projekt-spezifisch.
|
||||
============================================================ */}
|
||||
<h2 className="section-heading" data-i18n="admin.approval_policies.section.projects">
|
||||
Projekt-spezifisch
|
||||
</h2>
|
||||
<p className="form-hint" data-i18n="admin.approval_policies.section.projects.hint">
|
||||
Eigene Regeln für ein Projekt. Überschreiben Standards aus Partner Units und
|
||||
geerbten Projektregeln.
|
||||
</p>
|
||||
|
||||
<div className="ap-project-picker">
|
||||
<label htmlFor="ap-project-search" data-i18n="admin.approval_policies.picker.label">
|
||||
Projekt wählen
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ap-project-search"
|
||||
className="ap-project-search"
|
||||
data-i18n-placeholder="admin.approval_policies.picker.placeholder"
|
||||
placeholder="Suchen..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div className="ap-project-results" id="ap-project-results" />
|
||||
</div>
|
||||
|
||||
<div className="ap-project-matrix" id="ap-project-matrix" style="display:none">
|
||||
<div className="ap-project-header">
|
||||
<h3 id="ap-project-title" />
|
||||
<button type="button" className="btn-secondary btn-small" id="ap-bulk-apply-btn"
|
||||
data-i18n="admin.approval_policies.bulk.cta">
|
||||
Auf Unterprojekte anwenden
|
||||
</button>
|
||||
</div>
|
||||
<div className="ap-matrix-host" id="ap-matrix-host" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
</main>
|
||||
|
||||
{/* Bulk-apply confirm modal — populated client-side. */}
|
||||
<div className="modal-overlay" id="ap-bulk-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.approval_policies.bulk.modal.title">
|
||||
Auf Unterprojekte anwenden
|
||||
</h2>
|
||||
<button className="modal-close" id="ap-bulk-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="ap-bulk-body">
|
||||
<p data-i18n="admin.approval_policies.bulk.modal.body">
|
||||
Die folgenden Unterprojekte erhalten die effektive Matrix dieses Projekts als
|
||||
projektspezifische Regeln. Bestehende projektspezifische Regeln werden
|
||||
überschrieben. Standards aus Partner Units bleiben unberührt.
|
||||
</p>
|
||||
<ul className="ap-bulk-target-list" id="ap-bulk-target-list" />
|
||||
<p className="form-msg" id="ap-bulk-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="ap-bulk-cancel"
|
||||
data-i18n="admin.approval_policies.bulk.modal.cancel">Abbrechen</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime" id="ap-bulk-confirm"
|
||||
data-i18n="admin.approval_policies.bulk.modal.confirm">Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/admin-approval-policies.js" defer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -116,6 +117,7 @@ export function renderAdminAuditLog(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-audit-log.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
68
frontend/src/admin-broadcasts.tsx
Normal file
68
frontend/src/admin-broadcasts.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminBroadcasts(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.broadcasts.title">Broadcasts — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/broadcasts" />
|
||||
<BottomNav currentPath="/admin/broadcasts" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.broadcasts.heading">Broadcasts</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.broadcasts.subtitle">
|
||||
Versendete Massen-E-Mails an Teamauswahlen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<table className="entity-table entity-table--readonly broadcasts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.broadcasts.col.sent_at">Gesendet</th>
|
||||
<th data-i18n="admin.broadcasts.col.subject">Betreff</th>
|
||||
<th data-i18n="admin.broadcasts.col.sender">Absender:in</th>
|
||||
<th data-i18n="admin.broadcasts.col.count">Empfänger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="broadcasts-tbody">
|
||||
<tr><td colspan={4} data-i18n="admin.broadcasts.loading">Lade ...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="broadcasts-empty" style="display:none">
|
||||
<p data-i18n="admin.broadcasts.empty">Noch keine Broadcasts versandt.</p>
|
||||
</div>
|
||||
|
||||
<div id="broadcast-detail" className="hidden" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-broadcasts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -110,6 +111,7 @@ export function renderAdminEmailTemplatesEdit(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-email-templates-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -49,6 +50,7 @@ export function renderAdminEmailTemplates(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-email-templates.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -149,6 +150,7 @@ export function renderAdminEventTypes(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-event-types.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
114
frontend/src/admin-paliadin.tsx
Normal file
114
frontend/src/admin-paliadin.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Paliadin monitoring dashboard (t-paliad-146 PoC).
|
||||
//
|
||||
// global_admin only. The load-bearing artefact for §0.5.7's expansion
|
||||
// gate decision: m looks at this every week or two and decides if
|
||||
// Paliadin earns a production v1 build.
|
||||
export function renderAdminPaliadin(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.paliadin.title">Paliadin Monitor — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/paliadin" />
|
||||
<BottomNav currentPath="/admin/paliadin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.paliadin.heading">Paliadin Monitor</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.paliadin.subtitle">
|
||||
Wie wird Paliadin tatsächlich verwendet?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="paliadin-stats" id="paliadin-stats">
|
||||
<div className="paliadin-stat-cards">
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.total">Gesamt</div>
|
||||
<div className="paliadin-stat-value" id="stat-total">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.last7">Letzte 7 Tage</div>
|
||||
<div className="paliadin-stat-value" id="stat-7d">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.median_dur">Median Dauer</div>
|
||||
<div className="paliadin-stat-value" id="stat-median">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.tool_rate">Tool-Use Rate</div>
|
||||
<div className="paliadin-stat-value" id="stat-tools">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.abandon_rate">Abbruchrate</div>
|
||||
<div className="paliadin-stat-value" id="stat-abandon">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 data-i18n="admin.paliadin.classifier_heading">Anfragearten</h2>
|
||||
<div className="paliadin-classifier" id="classifier-bars" />
|
||||
|
||||
<h2 data-i18n="admin.paliadin.daily_heading">Tägliche Nutzung</h2>
|
||||
<div className="paliadin-spark" id="daily-spark" />
|
||||
|
||||
<h2 data-i18n="admin.paliadin.top_heading">Top Anfragen</h2>
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.count">Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-prompts-tbody">
|
||||
<tr><td colspan={2} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 data-i18n="admin.paliadin.recent_heading">Letzte Anfragen</h2>
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.started">Zeit</th>
|
||||
<th data-i18n="admin.paliadin.col.user">Nutzer</th>
|
||||
<th data-i18n="admin.paliadin.col.classifier">Art</th>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.response">Antwort</th>
|
||||
<th data-i18n="admin.paliadin.col.tools">Tools</th>
|
||||
<th data-i18n="admin.paliadin.col.origin">Seite</th>
|
||||
<th data-i18n="admin.paliadin.col.duration">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-turns-tbody">
|
||||
<tr><td colspan={8} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-paliadin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -124,6 +125,7 @@ export function renderAdminPartnerUnits(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-partner-units.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -72,6 +73,7 @@ export function renderAdminTeam(): string {
|
||||
<th data-i18n="admin.team.col.email">E-Mail</th>
|
||||
<th data-i18n="admin.team.col.office">Standort</th>
|
||||
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
|
||||
<th data-i18n="admin.team.col.profession">Profession</th>
|
||||
<th data-i18n="admin.team.col.permission">Berechtigung</th>
|
||||
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
|
||||
<th data-i18n="admin.team.col.lang">Sprache</th>
|
||||
@@ -80,7 +82,7 @@ export function renderAdminTeam(): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-team-tbody">
|
||||
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -131,6 +133,7 @@ export function renderAdminTeam(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-team.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -10,6 +11,7 @@ const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" str
|
||||
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
|
||||
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>';
|
||||
|
||||
interface PlannedCard {
|
||||
icon: string;
|
||||
@@ -83,6 +85,16 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
|
||||
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenführen, befördern.</p>
|
||||
</a>
|
||||
<a href="/admin/broadcasts" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
|
||||
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
|
||||
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
|
||||
</a>
|
||||
<a href="/admin/approval-policies" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
|
||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
@@ -101,6 +113,7 @@ export function renderAdmin(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -91,6 +92,7 @@ export function renderAgenda(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/agenda.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -94,6 +95,7 @@ export function renderAppointmentsCalendar(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -35,6 +36,9 @@ export function renderAppointmentsDetail(): string {
|
||||
<div id="appointment-body" style="display:none">
|
||||
<div className="tool-header">
|
||||
<span className="termin-type-badge" id="appointment-type-badge" />
|
||||
<span id="appointment-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
|
||||
Wartet auf Genehmigung
|
||||
</span>
|
||||
<h1 id="appointment-title-display" />
|
||||
<p className="tool-subtitle" id="appointment-time-display" />
|
||||
</div>
|
||||
@@ -94,6 +98,7 @@ export function renderAppointmentsDetail(): string {
|
||||
<p className="form-msg" id="appointment-edit-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" id="appointment-withdraw-btn" className="btn-secondary" style="display:none" data-i18n="approvals.withdraw.cta">Genehmigungsanfrage zurückziehen</button>
|
||||
<button type="button" id="appointment-delete-btn" className="btn-danger" data-i18n="appointments.detail.delete">Termin löschen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.detail.save">Änderungen speichern</button>
|
||||
</div>
|
||||
@@ -104,6 +109,7 @@ export function renderAppointmentsDetail(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -86,6 +87,14 @@ export function renderAppointmentsNew(): string {
|
||||
|
||||
<p className="form-msg" id="appointment-new-msg" />
|
||||
|
||||
{/* t-paliad-154 — form-time 4-eye hint. */}
|
||||
<div className="approval-hint" id="appointment-approval-hint" style="display:none">
|
||||
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
|
||||
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
|
||||
}} />
|
||||
<span id="appointment-approval-hint-text" />
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<a href="/events?type=appointment" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.neu.submit">Termin anlegen</button>
|
||||
@@ -96,6 +105,7 @@ export function renderAppointmentsNew(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -41,6 +42,7 @@ export function renderChangelog(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/changelog.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -152,6 +153,7 @@ export function renderChecklistsDetail(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -118,6 +119,7 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-instance.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -74,6 +75,7 @@ export function renderChecklists(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
661
frontend/src/client/admin-approval-policies.ts
Normal file
661
frontend/src/client/admin-approval-policies.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-154 — admin approval-policy authoring page orchestration.
|
||||
//
|
||||
// Two sections: Partner-Unit-Standards (accordion list) and Projekt-spezifisch
|
||||
// (project picker → 8-cell matrix). Edits hit the per-scope CRUD endpoints
|
||||
// from the same page; re-renders refresh from server state to surface
|
||||
// inheritance changes.
|
||||
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
office: string;
|
||||
}
|
||||
|
||||
interface UnitPolicy {
|
||||
id: string;
|
||||
partner_unit_id: string | null;
|
||||
project_id: string | null;
|
||||
entity_type: string;
|
||||
lifecycle_event: string;
|
||||
// t-paliad-160 split-grammar.
|
||||
requires_approval: boolean;
|
||||
min_role?: string | null;
|
||||
}
|
||||
|
||||
interface EffectivePolicy {
|
||||
entity_type: string;
|
||||
lifecycle_event: string;
|
||||
requires_approval: boolean;
|
||||
min_role?: string | null;
|
||||
source?: string | null;
|
||||
source_id?: string | null;
|
||||
source_name?: string | null;
|
||||
}
|
||||
|
||||
interface ProjectNode {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
type?: string;
|
||||
parent_id?: string | null;
|
||||
children?: ProjectNode[];
|
||||
}
|
||||
|
||||
const ENTITY_TYPES = ["deadline", "appointment"] as const;
|
||||
const LIFECYCLES = ["create", "update", "complete", "delete"] as const;
|
||||
// Strict-ladder roles only. The legacy "none" sentinel is gone — its job
|
||||
// (suppress the gate) is now done by the requires_approval=false checkbox
|
||||
// (t-paliad-160 §A).
|
||||
const ROLE_OPTIONS = [
|
||||
"partner",
|
||||
"of_counsel",
|
||||
"associate",
|
||||
"senior_pa",
|
||||
"pa",
|
||||
];
|
||||
|
||||
let partnerUnits: PartnerUnit[] = [];
|
||||
let unitPolicies: Record<string, UnitPolicy[]> = {};
|
||||
let allProjects: ProjectNode[] = [];
|
||||
let selectedProjectID: string | null = null;
|
||||
let selectedProjectTitle: string = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean): void {
|
||||
const el = document.getElementById("ap-feedback");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Loaders.
|
||||
// ============================================================================
|
||||
|
||||
async function loadPartnerUnits(): Promise<void> {
|
||||
const resp = await fetch("/api/partner-units");
|
||||
if (!resp.ok) {
|
||||
partnerUnits = [];
|
||||
return;
|
||||
}
|
||||
partnerUnits = (await resp.json()) as PartnerUnit[];
|
||||
}
|
||||
|
||||
async function loadUnitPolicies(unitID: string): Promise<UnitPolicy[]> {
|
||||
const resp = await fetch(`/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies`);
|
||||
if (!resp.ok) return [];
|
||||
return (await resp.json()) as UnitPolicy[];
|
||||
}
|
||||
|
||||
async function loadAllUnitPolicies(): Promise<void> {
|
||||
const out: Record<string, UnitPolicy[]> = {};
|
||||
for (const u of partnerUnits) {
|
||||
out[u.id] = await loadUnitPolicies(u.id);
|
||||
}
|
||||
unitPolicies = out;
|
||||
}
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
const resp = await fetch("/api/projects/tree");
|
||||
if (!resp.ok) {
|
||||
allProjects = [];
|
||||
return;
|
||||
}
|
||||
const tree = (await resp.json()) as ProjectNode[];
|
||||
allProjects = flattenTree(tree);
|
||||
}
|
||||
|
||||
function flattenTree(nodes: ProjectNode[]): ProjectNode[] {
|
||||
const out: ProjectNode[] = [];
|
||||
const walk = (n: ProjectNode): void => {
|
||||
out.push(n);
|
||||
if (n.children) n.children.forEach(walk);
|
||||
};
|
||||
nodes.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadMatrix(projectID: string): Promise<EffectivePolicy[]> {
|
||||
const resp = await fetch(`/api/admin/approval-policies/matrix?project_id=${encodeURIComponent(projectID)}`);
|
||||
if (!resp.ok) return [];
|
||||
return (await resp.json()) as EffectivePolicy[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rendering — partner unit accordion.
|
||||
// ============================================================================
|
||||
|
||||
function lifecycleLabel(l: string): string {
|
||||
return tDyn("admin.approval_policies.lifecycle." + l) || l;
|
||||
}
|
||||
|
||||
function entityLabel(e: string): string {
|
||||
return tDyn("admin.approval_policies.entity." + e) || e;
|
||||
}
|
||||
|
||||
function roleLabel(r: string): string {
|
||||
return tDyn("admin.approval_policies.role." + r) || r;
|
||||
}
|
||||
|
||||
function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): UnitPolicy | undefined {
|
||||
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
|
||||
}
|
||||
|
||||
// Cell control state, t-paliad-160 §A.
|
||||
// none → no project-specific rule authored (the cell inherits).
|
||||
// off → requires_approval=false explicitly authored.
|
||||
// on(role) → requires_approval=true with the given min_role.
|
||||
//
|
||||
// rendered as: [✓] requires approval [role select]
|
||||
// - checkbox unchecked → role select disabled (greyed).
|
||||
// - checkbox checked → role select enabled, min_role required.
|
||||
// - "no rule" — surfaced as a third button next to the controls so the
|
||||
// admin can explicitly clear an authored cell back to inheritance.
|
||||
type CellAuthored =
|
||||
| { kind: "none" }
|
||||
| { kind: "off" }
|
||||
| { kind: "on"; role: string };
|
||||
|
||||
function authoredFromUnitPolicy(p: UnitPolicy | undefined): CellAuthored {
|
||||
if (!p) return { kind: "none" };
|
||||
if (!p.requires_approval) return { kind: "off" };
|
||||
return { kind: "on", role: p.min_role || "associate" };
|
||||
}
|
||||
|
||||
function authoredFromEffective(r: EffectivePolicy): CellAuthored {
|
||||
if (r.source !== "project") return { kind: "none" };
|
||||
if (!r.requires_approval) return { kind: "off" };
|
||||
return { kind: "on", role: r.min_role || "associate" };
|
||||
}
|
||||
|
||||
function renderCellControls(authored: CellAuthored, dataAttrs: string): string {
|
||||
const checked = authored.kind === "on";
|
||||
const disabled = authored.kind !== "on";
|
||||
const role = authored.kind === "on" ? authored.role : "associate";
|
||||
const opts = ROLE_OPTIONS.map((r) =>
|
||||
`<option value="${esc(r)}"${role === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`
|
||||
).join("");
|
||||
const reqLabel = esc(t("admin.approval_policies.cell.requires") || "Genehmigung");
|
||||
const clearLabel = esc(t("admin.approval_policies.cell.clear") || "—");
|
||||
const clearTitle = esc(t("admin.approval_policies.cell.clear.title") || "Regel zurücksetzen (erben)");
|
||||
const cleared = authored.kind === "none";
|
||||
return `
|
||||
<label class="ap-cell-toggle">
|
||||
<input type="checkbox" class="ap-cell-requires" ${dataAttrs}${checked ? " checked" : ""} aria-label="${reqLabel}" />
|
||||
<span class="ap-cell-toggle-label">${reqLabel}</span>
|
||||
</label>
|
||||
<select class="ap-cell-role" ${dataAttrs}${disabled ? " disabled" : ""}>${opts}</select>
|
||||
<button type="button" class="ap-cell-clear" ${dataAttrs} title="${clearTitle}"${cleared ? " disabled" : ""}>${clearLabel}</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUnitMatrix(unit: PartnerUnit): string {
|
||||
const rows = unitPolicies[unit.id] || [];
|
||||
const buildCell = (e: string, l: string): string => {
|
||||
const p = policyForCell(rows, e, l);
|
||||
const authored = authoredFromUnitPolicy(p);
|
||||
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
||||
return `<div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>`;
|
||||
};
|
||||
|
||||
let cells = "";
|
||||
for (const e of ENTITY_TYPES) {
|
||||
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
cells += `<td class="ap-matrix-cell">${buildCell(e, l)}</td>`;
|
||||
}
|
||||
cells += `</tr>`;
|
||||
}
|
||||
|
||||
// Stacked sections for mobile (CSS toggles the table vs the list).
|
||||
let stacked = "";
|
||||
for (const e of ENTITY_TYPES) {
|
||||
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
stacked += `<div class="ap-matrix-row">
|
||||
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
|
||||
${buildCell(e, l)}
|
||||
</div>`;
|
||||
}
|
||||
stacked += `</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<table class="ap-matrix">
|
||||
<thead><tr><th></th>
|
||||
<th>${esc(lifecycleLabel("create"))}</th>
|
||||
<th>${esc(lifecycleLabel("update"))}</th>
|
||||
<th>${esc(lifecycleLabel("complete"))}</th>
|
||||
<th>${esc(lifecycleLabel("delete"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${cells}</tbody>
|
||||
</table>
|
||||
<div class="ap-matrix-stacked">${stacked}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUnits(): void {
|
||||
const host = document.getElementById("ap-units-list");
|
||||
if (!host) return;
|
||||
// Preserve which unit blocks were expanded across re-renders. Without this,
|
||||
// changing any cell's required_role saves and re-renders, collapsing the
|
||||
// accordion the admin was working in (m, 2026-05-08).
|
||||
const openUnitIDs = new Set<string>();
|
||||
host.querySelectorAll<HTMLDetailsElement>("details.ap-unit-block").forEach((d) => {
|
||||
if (d.open && d.dataset.unitId) openUnitIDs.add(d.dataset.unitId);
|
||||
});
|
||||
if (partnerUnits.length === 0) {
|
||||
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.units.empty") || "Keine Partner Units vorhanden.")}</p>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = partnerUnits.map((u) => `
|
||||
<details class="ap-unit-block" data-unit-id="${esc(u.id)}"${openUnitIDs.has(u.id) ? " open" : ""}>
|
||||
<summary class="ap-unit-summary">
|
||||
<span class="ap-unit-name">${esc(u.name)}</span>
|
||||
<span class="office-chip office-${esc(u.office)}">${esc(u.office)}</span>
|
||||
</summary>
|
||||
<div class="ap-unit-body">
|
||||
${renderUnitMatrix(u)}
|
||||
</div>
|
||||
</details>
|
||||
`).join("");
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rendering — project matrix with attribution chips.
|
||||
// ============================================================================
|
||||
|
||||
function renderProjectMatrix(rows: EffectivePolicy[]): string {
|
||||
const cell = (r: EffectivePolicy): string => {
|
||||
const own = r.source === "project";
|
||||
const attrs = `data-scope="project" data-project-id="${escAttr(selectedProjectID || "")}" data-entity="${esc(r.entity_type)}" data-lifecycle="${esc(r.lifecycle_event)}"`;
|
||||
// The controls show the AUTHORED state — the project row's own values
|
||||
// when there is one, else the inherited state is rendered via the
|
||||
// attribution chip and the controls sit unset (kind="none"). Most-
|
||||
// strict-wins inheritance from ancestors / unit defaults is purely
|
||||
// informational on this row; flipping the controls writes a new
|
||||
// project-specific row.
|
||||
const authored = authoredFromEffective(r);
|
||||
let chip = "";
|
||||
if (r.source && !own && r.requires_approval) {
|
||||
// Inherited from ancestor or unit default. Surface attribution +
|
||||
// the inherited min_role so the admin sees what the cell is
|
||||
// resolving to before they author an override.
|
||||
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
||||
r.source === "unit_default" ? "admin.approval_policies.source.unit_default" :
|
||||
"admin.approval_policies.source.project";
|
||||
const label = tDyn(sourceKey) || r.source;
|
||||
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
||||
const role = r.min_role ? ` · ${esc(roleLabel(r.min_role))}+` : "";
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}${role}</span>`;
|
||||
} else if (r.source && !own && !r.requires_approval) {
|
||||
// Inherited "no approval needed" — distinct from "no rule at all".
|
||||
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
||||
"admin.approval_policies.source.unit_default";
|
||||
const label = tDyn(sourceKey) || r.source;
|
||||
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
||||
const offLabel = esc(t("admin.approval_policies.source.no_approval") || "keine Genehmigung");
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name} · ${offLabel}</span>`;
|
||||
} else if (own) {
|
||||
chip = `<span class="ap-source-chip ap-source-project">${esc(t("admin.approval_policies.source.project") || "Projekt")}</span>`;
|
||||
}
|
||||
return `<div class="ap-cell-wrap"><div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>${chip}</div>`;
|
||||
};
|
||||
|
||||
const byCell = new Map<string, EffectivePolicy>();
|
||||
for (const r of rows) byCell.set(`${r.entity_type}:${r.lifecycle_event}`, r);
|
||||
const cellFor = (e: string, l: string): EffectivePolicy =>
|
||||
byCell.get(`${e}:${l}`) || { entity_type: e, lifecycle_event: l };
|
||||
|
||||
let table = "";
|
||||
for (const e of ENTITY_TYPES) {
|
||||
table += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
table += `<td class="ap-matrix-cell">${cell(cellFor(e, l))}</td>`;
|
||||
}
|
||||
table += `</tr>`;
|
||||
}
|
||||
|
||||
let stacked = "";
|
||||
for (const e of ENTITY_TYPES) {
|
||||
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
stacked += `<div class="ap-matrix-row">
|
||||
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
|
||||
${cell(cellFor(e, l))}
|
||||
</div>`;
|
||||
}
|
||||
stacked += `</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<table class="ap-matrix">
|
||||
<thead><tr><th></th>
|
||||
<th>${esc(lifecycleLabel("create"))}</th>
|
||||
<th>${esc(lifecycleLabel("update"))}</th>
|
||||
<th>${esc(lifecycleLabel("complete"))}</th>
|
||||
<th>${esc(lifecycleLabel("delete"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${table}</tbody>
|
||||
</table>
|
||||
<div class="ap-matrix-stacked">${stacked}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function selectProject(p: ProjectNode): Promise<void> {
|
||||
selectedProjectID = p.id;
|
||||
selectedProjectTitle = p.title;
|
||||
const matrix = await loadMatrix(p.id);
|
||||
const wrap = document.getElementById("ap-project-matrix");
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
const titleEl = document.getElementById("ap-project-title");
|
||||
if (!wrap || !host || !titleEl) return;
|
||||
wrap.style.display = "block";
|
||||
titleEl.textContent = p.title + (p.reference ? ` · ${p.reference}` : "");
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
|
||||
function renderProjectResults(filter: string): void {
|
||||
const host = document.getElementById("ap-project-results");
|
||||
if (!host) return;
|
||||
const q = filter.trim().toLowerCase();
|
||||
let matches = allProjects;
|
||||
if (q.length > 0) {
|
||||
matches = allProjects.filter((p) => {
|
||||
const t = (p.title || "").toLowerCase();
|
||||
const r = (p.reference || "").toLowerCase();
|
||||
return t.includes(q) || r.includes(q);
|
||||
});
|
||||
}
|
||||
matches = matches.slice(0, 30);
|
||||
if (matches.length === 0) {
|
||||
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.picker.no_results") || "Keine Treffer.")}</p>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = matches.map((p) => `
|
||||
<button type="button" class="ap-project-result" data-id="${escAttr(p.id)}">
|
||||
<span class="ap-project-result-title">${esc(p.title)}</span>
|
||||
${p.reference ? `<span class="ap-project-result-ref">${esc(p.reference)}</span>` : ""}
|
||||
</button>
|
||||
`).join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".ap-project-result").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.dataset.id || "";
|
||||
const p = allProjects.find((x) => x.id === id);
|
||||
if (p) void selectProject(p);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cell change → server.
|
||||
// ============================================================================
|
||||
|
||||
function bindCellChangeHandlers(scope: HTMLElement): void {
|
||||
// Each cell now has THREE controls — the requires-approval checkbox,
|
||||
// the role select, and the explicit "clear / inherit" button. They all
|
||||
// share data-* attrs so onCellChange can derive the URL + intended
|
||||
// post-state from any of them.
|
||||
scope.querySelectorAll<HTMLInputElement>(".ap-cell-requires").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
// Toggle the sibling role select disabled state immediately for
|
||||
// visual feedback — the server PUT might lag.
|
||||
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
||||
if (sel) sel.disabled = !cb.checked;
|
||||
void onCellChangeFromCheckbox(cb);
|
||||
});
|
||||
});
|
||||
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-role").forEach((sel) => {
|
||||
sel.addEventListener("change", () => void onCellChangeFromRole(sel));
|
||||
});
|
||||
scope.querySelectorAll<HTMLButtonElement>(".ap-cell-clear").forEach((btn) => {
|
||||
btn.addEventListener("click", () => void onCellClear(btn));
|
||||
});
|
||||
}
|
||||
|
||||
function cellEndpointURL(el: HTMLElement): string | null {
|
||||
const scope = el.dataset.scope;
|
||||
const entity = el.dataset.entity || "";
|
||||
const lifecycle = el.dataset.lifecycle || "";
|
||||
if (scope === "unit") {
|
||||
const unitID = el.dataset.unitId || "";
|
||||
return `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
}
|
||||
if (scope === "project") {
|
||||
const projectID = el.dataset.projectId || "";
|
||||
return `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function onCellChangeFromCheckbox(cb: HTMLInputElement): Promise<void> {
|
||||
// Checkbox flipped — write the cell as (requires_approval, min_role).
|
||||
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
||||
const requires = cb.checked;
|
||||
const minRole = requires ? (sel?.value || "associate") : null;
|
||||
await putCellSplit(cb, requires, minRole);
|
||||
}
|
||||
|
||||
async function onCellChangeFromRole(sel: HTMLSelectElement): Promise<void> {
|
||||
// Role select changed — only meaningful when the checkbox is on (the
|
||||
// disabled state would block this on a real interaction, but pin it
|
||||
// for safety).
|
||||
const wrap = sel.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const cb = wrap?.querySelector<HTMLInputElement>(".ap-cell-requires");
|
||||
if (!cb || !cb.checked) return;
|
||||
await putCellSplit(sel, true, sel.value);
|
||||
}
|
||||
|
||||
async function onCellClear(btn: HTMLButtonElement): Promise<void> {
|
||||
// Explicit "back to inheritance" — DELETE the project / unit row.
|
||||
const url = cellEndpointURL(btn);
|
||||
if (!url) return;
|
||||
try {
|
||||
const resp = await fetch(url, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
||||
await refreshAfterCellMutation(btn);
|
||||
} catch (err) {
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function putCellSplit(el: HTMLElement, requires: boolean, minRole: string | null): Promise<void> {
|
||||
const url = cellEndpointURL(el);
|
||||
if (!url) return;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requires_approval: requires, min_role: minRole }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
||||
await refreshAfterCellMutation(el);
|
||||
} catch (err) {
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterCellMutation(el: HTMLElement): Promise<void> {
|
||||
const scope = el.dataset.scope;
|
||||
if (scope === "unit") {
|
||||
const unitID = el.dataset.unitId || "";
|
||||
unitPolicies[unitID] = await loadUnitPolicies(unitID);
|
||||
renderUnits();
|
||||
} else if (selectedProjectID) {
|
||||
const matrix = await loadMatrix(selectedProjectID);
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
if (host) {
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bulk-apply to descendants.
|
||||
// ============================================================================
|
||||
|
||||
function descendantsOf(rootID: string): ProjectNode[] {
|
||||
// Build parent-child map from the flat list.
|
||||
const byParent = new Map<string, ProjectNode[]>();
|
||||
for (const p of allProjects) {
|
||||
const parent = p.parent_id || "";
|
||||
if (!byParent.has(parent)) byParent.set(parent, []);
|
||||
byParent.get(parent)!.push(p);
|
||||
}
|
||||
const out: ProjectNode[] = [];
|
||||
const walk = (id: string): void => {
|
||||
const kids = byParent.get(id) || [];
|
||||
for (const k of kids) {
|
||||
out.push(k);
|
||||
walk(k.id);
|
||||
}
|
||||
};
|
||||
walk(rootID);
|
||||
return out;
|
||||
}
|
||||
|
||||
function openBulkModal(): void {
|
||||
if (!selectedProjectID) return;
|
||||
const targets = descendantsOf(selectedProjectID);
|
||||
const list = document.getElementById("ap-bulk-target-list");
|
||||
const modal = document.getElementById("ap-bulk-modal");
|
||||
if (!list || !modal) return;
|
||||
if (targets.length === 0) {
|
||||
showFeedback(t("admin.approval_policies.bulk.no_descendants") || "Keine Unterprojekte vorhanden.", true);
|
||||
return;
|
||||
}
|
||||
list.innerHTML = targets.map((p) => `
|
||||
<li><span class="ap-bulk-target-title">${esc(p.title)}</span>${p.reference ? ` <span class="ap-bulk-target-ref">${esc(p.reference)}</span>` : ""}</li>
|
||||
`).join("");
|
||||
modal.style.display = "flex";
|
||||
modal.dataset.targets = JSON.stringify(targets.map((p) => p.id));
|
||||
}
|
||||
|
||||
function closeBulkModal(): void {
|
||||
const modal = document.getElementById("ap-bulk-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
async function confirmBulk(): Promise<void> {
|
||||
if (!selectedProjectID) return;
|
||||
const modal = document.getElementById("ap-bulk-modal");
|
||||
const msg = document.getElementById("ap-bulk-msg");
|
||||
if (!modal || !msg) return;
|
||||
const targets = JSON.parse(modal.dataset.targets || "[]") as string[];
|
||||
msg.textContent = t("admin.approval_policies.bulk.modal.applying") || "Übernehme …";
|
||||
try {
|
||||
const resp = await fetch("/api/admin/approval-policies/apply-to-descendants", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source_project_id: selectedProjectID,
|
||||
target_project_ids: targets,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${body}`;
|
||||
return;
|
||||
}
|
||||
const out = await resp.json() as { writes: number; targets: number };
|
||||
closeBulkModal();
|
||||
showFeedback(
|
||||
(t("admin.approval_policies.bulk.modal.done") || "Übernommen") +
|
||||
` — ${out.writes} ${t("admin.approval_policies.bulk.modal.writes_label") || "Schreibvorgänge"} auf ${out.targets} ${t("admin.approval_policies.bulk.modal.targets_label") || "Projekte"}.`,
|
||||
false,
|
||||
);
|
||||
// Re-fetch the source matrix so any cells the bulk-apply touched on
|
||||
// descendants are reflected via inheritance attribution if applicable.
|
||||
if (selectedProjectID) {
|
||||
const matrix = await loadMatrix(selectedProjectID);
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
if (host) {
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wire-up.
|
||||
// ============================================================================
|
||||
|
||||
function wirePicker(): void {
|
||||
const input = document.getElementById("ap-project-search") as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.addEventListener("input", () => renderProjectResults(input.value));
|
||||
// Initial empty-search renders top-of-list.
|
||||
renderProjectResults("");
|
||||
}
|
||||
|
||||
function wireBulk(): void {
|
||||
const btn = document.getElementById("ap-bulk-apply-btn");
|
||||
const close = document.getElementById("ap-bulk-close");
|
||||
const cancel = document.getElementById("ap-bulk-cancel");
|
||||
const confirm = document.getElementById("ap-bulk-confirm");
|
||||
if (btn) btn.addEventListener("click", openBulkModal);
|
||||
if (close) close.addEventListener("click", closeBulkModal);
|
||||
if (cancel) cancel.addEventListener("click", closeBulkModal);
|
||||
if (confirm) confirm.addEventListener("click", () => void confirmBulk());
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
await Promise.all([loadPartnerUnits(), loadProjects()]);
|
||||
await loadAllUnitPolicies();
|
||||
renderUnits();
|
||||
wirePicker();
|
||||
wireBulk();
|
||||
onLangChange(() => {
|
||||
renderUnits();
|
||||
if (selectedProjectID) {
|
||||
void loadMatrix(selectedProjectID).then((matrix) => {
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
if (host) {
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
});
|
||||
}
|
||||
renderProjectResults((document.getElementById("ap-project-search") as HTMLInputElement | null)?.value || "");
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => void init());
|
||||
} else {
|
||||
void init();
|
||||
}
|
||||
137
frontend/src/client/admin-broadcasts.ts
Normal file
137
frontend/src/client/admin-broadcasts.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// admin-broadcasts.ts — read-only viewer for paliad.email_broadcasts.
|
||||
//
|
||||
// global_admin sees every row; senders see only their own. Authority is
|
||||
// enforced server-side; this client just renders whatever /api/admin/broadcasts
|
||||
// returns. Click a row → load detail (subject, body, recipient list).
|
||||
|
||||
import { initI18n, onLangChange, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface BroadcastRow {
|
||||
id: string;
|
||||
subject: string;
|
||||
sender_id: string;
|
||||
sender_name: string;
|
||||
sender_email: string;
|
||||
recipient_count: number;
|
||||
sent_at: string;
|
||||
template_key?: string;
|
||||
}
|
||||
|
||||
interface BroadcastDetailRecipient {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface BroadcastDetail extends BroadcastRow {
|
||||
body: string;
|
||||
recipient_filter: Record<string, unknown>;
|
||||
send_report: { total: number; sent: number; failed: number };
|
||||
recipients: BroadcastDetailRecipient[];
|
||||
}
|
||||
|
||||
let rows: BroadcastRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tbody = document.getElementById("broadcasts-tbody")!;
|
||||
const empty = document.getElementById("broadcasts-empty")!;
|
||||
try {
|
||||
const res = await fetch("/api/admin/broadcasts");
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.forbidden") || "Zugriff verweigert.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
rows = (await res.json()) as BroadcastRow[];
|
||||
} catch {
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = rows
|
||||
.map(
|
||||
(r) => `
|
||||
<tr data-broadcast-id="${esc(r.id)}">
|
||||
<td>${esc(fmtDate(r.sent_at))}</td>
|
||||
<td>${esc(r.subject)}</td>
|
||||
<td>${esc(r.sender_name || r.sender_email || "—")}</td>
|
||||
<td>${r.recipient_count}</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
tbody.querySelectorAll<HTMLTableRowElement>("tr[data-broadcast-id]").forEach((tr) => {
|
||||
tr.addEventListener("click", () => loadDetail(tr.dataset.broadcastId!));
|
||||
tr.style.cursor = "pointer";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDetail(id: string): Promise<void> {
|
||||
const detail = document.getElementById("broadcast-detail")!;
|
||||
detail.classList.remove("hidden");
|
||||
detail.innerHTML = `<p>${esc(t("common.loading") || "Lade…")}</p>`;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/broadcasts/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) {
|
||||
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const d = (await res.json()) as BroadcastDetail;
|
||||
const recList = (d.recipients || [])
|
||||
.map(
|
||||
(r) =>
|
||||
`<li>${esc(r.display_name || "—")} <span class="broadcast-recip-email"><${esc(r.email)}></span></li>`,
|
||||
)
|
||||
.join("");
|
||||
const report = d.send_report || { total: d.recipient_count, sent: d.recipient_count, failed: 0 };
|
||||
detail.innerHTML = `
|
||||
<article class="card broadcast-detail-card">
|
||||
<header>
|
||||
<h2>${esc(d.subject)}</h2>
|
||||
<p class="muted">
|
||||
${esc(t("admin.broadcasts.detail.sent_by") || "Gesendet von")} <strong>${esc(d.sender_name || d.sender_email)}</strong>
|
||||
• ${esc(fmtDate(d.sent_at))}
|
||||
• ${report.sent}/${report.total} ${esc(t("admin.broadcasts.detail.delivered") || "versandt")}
|
||||
${report.failed > 0 ? ` • ${report.failed} ${esc(t("admin.broadcasts.detail.failed") || "fehlgeschlagen")}` : ""}
|
||||
</p>
|
||||
</header>
|
||||
<div class="broadcast-detail-body">${esc(d.body)}</div>
|
||||
<section class="broadcast-detail-recipients">
|
||||
<h3>${esc(t("admin.broadcasts.detail.recipients") || "Empfänger")} (${d.recipients?.length ?? 0})</h3>
|
||||
<ul>${recList}</ul>
|
||||
</section>
|
||||
</article>
|
||||
`;
|
||||
detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
} catch {
|
||||
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => load());
|
||||
load();
|
||||
});
|
||||
198
frontend/src/client/admin-paliadin.ts
Normal file
198
frontend/src/client/admin-paliadin.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Paliadin admin dashboard client (t-paliad-146 PoC).
|
||||
//
|
||||
// Reads /api/admin/paliadin/stats + /api/admin/paliadin/turns and
|
||||
// renders the cards / bars / sparkline / tables. Pure read-only;
|
||||
// dashboard refreshes on each visit (no live polling — m comes here
|
||||
// every few days, not every few seconds).
|
||||
|
||||
interface Stats {
|
||||
total_turns: number;
|
||||
turns_last_7_days: number;
|
||||
median_duration_ms: number;
|
||||
p90_duration_ms: number;
|
||||
tool_use_rate: number;
|
||||
abandon_rate: number;
|
||||
by_classifier: Record<string, number>;
|
||||
daily_counts: { day: string; count: number }[];
|
||||
top_prompts: { prompt: string; count: number }[];
|
||||
}
|
||||
|
||||
interface Turn {
|
||||
turn_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
user_display_name: string | null;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
user_message: string;
|
||||
response: string | null;
|
||||
used_tools: string[] | null;
|
||||
rows_seen: number[] | null;
|
||||
classifier_tag: string | null;
|
||||
abandoned: boolean;
|
||||
error_code: string | null;
|
||||
page_origin: string | null;
|
||||
chip_count: number;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const [stats, turns] = await Promise.all([
|
||||
fetchJSON<Stats>("/api/admin/paliadin/stats"),
|
||||
fetchJSON<Turn[]>("/api/admin/paliadin/turns"),
|
||||
]);
|
||||
|
||||
if (stats) renderStats(stats);
|
||||
if (turns) renderTurns(turns);
|
||||
});
|
||||
|
||||
async function fetchJSON<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(s: Stats): void {
|
||||
setText("stat-total", String(s.total_turns));
|
||||
setText("stat-7d", String(s.turns_last_7_days));
|
||||
setText("stat-median", formatMs(s.median_duration_ms));
|
||||
setText("stat-tools", formatPct(s.tool_use_rate));
|
||||
setText("stat-abandon", formatPct(s.abandon_rate));
|
||||
|
||||
// Classifier histogram bars.
|
||||
const cont = document.getElementById("classifier-bars");
|
||||
if (cont) {
|
||||
const entries = Object.entries(s.by_classifier).sort((a, b) => b[1] - a[1]);
|
||||
const max = Math.max(...entries.map((e) => e[1]), 1);
|
||||
cont.innerHTML = entries
|
||||
.map(([tag, n]) => {
|
||||
const pct = (n / max) * 100;
|
||||
return `<div class="paliadin-classifier-row">
|
||||
<div class="paliadin-classifier-label">${escapeHTML(tag)}</div>
|
||||
<div class="paliadin-classifier-bar"><div class="paliadin-classifier-fill" style="width:${pct}%"></div></div>
|
||||
<div class="paliadin-classifier-count">${n}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Daily sparkline (last 30 days, vertical bars).
|
||||
const spark = document.getElementById("daily-spark");
|
||||
if (spark) {
|
||||
const days = s.daily_counts;
|
||||
const max = Math.max(...days.map((d) => d.count), 1);
|
||||
spark.innerHTML = days
|
||||
.map((d) => {
|
||||
const h = (d.count / max) * 60;
|
||||
return `<div class="paliadin-spark-bar" style="height:${h}px" title="${d.day}: ${d.count}"></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Top prompts table.
|
||||
const tbody = document.getElementById("top-prompts-tbody");
|
||||
if (tbody) {
|
||||
if (s.top_prompts.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="2">Noch keine Daten.</td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = s.top_prompts
|
||||
.map(
|
||||
(p) =>
|
||||
`<tr><td>${escapeHTML(p.prompt)}</td><td>${p.count}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurns(turns: Turn[]): void {
|
||||
const tbody = document.getElementById("recent-turns-tbody");
|
||||
if (!tbody) return;
|
||||
if (turns.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="8">Noch keine Anfragen.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = turns
|
||||
.map((t) => {
|
||||
const tag = t.classifier_tag || "—";
|
||||
// Tools cell pairs each tool name with its rows_seen count when
|
||||
// available — "list_my_projects (11), search_my_deadlines (18)" —
|
||||
// so the meta is legible at a glance instead of hidden in a side
|
||||
// table. Falls back to "—" for casual chats with no tool calls.
|
||||
const tools = t.used_tools && t.used_tools.length > 0
|
||||
? t.used_tools
|
||||
.map((name, i) => {
|
||||
const r = t.rows_seen?.[i];
|
||||
return r != null ? `${name} (${r})` : name;
|
||||
})
|
||||
.join(", ")
|
||||
: "—";
|
||||
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
|
||||
const errMark = t.error_code ? ` ⚠ ${t.error_code}` : "";
|
||||
const userLabel = t.user_display_name || t.user_email || t.user_id.slice(0, 8);
|
||||
const userTitle = [t.user_email, t.user_display_name].filter(Boolean).join(" · ") || t.user_id;
|
||||
// Response preview — first 200 chars of cleanBody. Full response
|
||||
// available on hover via the title attribute.
|
||||
const respPreview = t.response ? truncate(t.response, 80) : "—";
|
||||
const respTitle = t.response || "";
|
||||
const origin = t.page_origin || "—";
|
||||
return `<tr>
|
||||
<td>${formatTime(t.started_at)}</td>
|
||||
<td title="${escapeAttr(userTitle)}">${escapeHTML(userLabel)}</td>
|
||||
<td>${escapeHTML(tag)}</td>
|
||||
<td title="${escapeAttr(t.user_message)}">${escapeHTML(truncate(t.user_message, 80))}${errMark}</td>
|
||||
<td title="${escapeAttr(respTitle)}">${escapeHTML(respPreview)}</td>
|
||||
<td>${escapeHTML(tools)}</td>
|
||||
<td>${escapeHTML(origin)}</td>
|
||||
<td>${dur}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/\n/g, " ");
|
||||
}
|
||||
|
||||
function setText(id: string, val: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function formatPct(r: number): string {
|
||||
return `${Math.round(r * 100)} %`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(0, n - 1) + "…";
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface PartnerUnit {
|
||||
@@ -16,8 +16,11 @@ interface Member {
|
||||
display_name: string;
|
||||
office: string;
|
||||
job_title: string | null;
|
||||
unit_role: string;
|
||||
}
|
||||
|
||||
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
|
||||
|
||||
interface PartnerUnitWithMembers extends PartnerUnit {
|
||||
lead_display_name?: string;
|
||||
lead_email?: string;
|
||||
@@ -284,16 +287,54 @@ function renderMemberList(): void {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = u.members
|
||||
.map(
|
||||
(m) => `<li class="partner-unit-member-item">
|
||||
.map((m) => {
|
||||
const roleOptions = UNIT_ROLES.map((r) => {
|
||||
const label = tDyn(`unit_role.${r}`) || r;
|
||||
const sel = m.unit_role === r ? " selected" : "";
|
||||
return `<option value="${esc(r)}"${sel}>${esc(label)}</option>`;
|
||||
}).join("");
|
||||
return `<li class="partner-unit-member-item">
|
||||
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</li>`,
|
||||
)
|
||||
<span class="partner-unit-member-actions">
|
||||
<select class="pu-role-select" data-user="${esc(m.user_id)}" aria-label="${escAttr(tDyn("admin.partner_units.member.role") || "Rolle")}">${roleOptions}</select>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => removeMember(b.dataset.user!)),
|
||||
);
|
||||
list.querySelectorAll<HTMLSelectElement>(".pu-role-select").forEach((s) =>
|
||||
s.addEventListener("change", () => setMemberRole(s.dataset.user!, s.value, s)),
|
||||
);
|
||||
}
|
||||
|
||||
async function setMemberRole(userID: string, role: string, sel: HTMLSelectElement): Promise<void> {
|
||||
if (!activeUnitID) return;
|
||||
// Snapshot the prior selection so we can roll back on failure.
|
||||
const u = units.find((x) => x.id === activeUnitID);
|
||||
const prior = u?.members.find((m) => m.user_id === userID)?.unit_role;
|
||||
sel.disabled = true;
|
||||
const resp = await fetch(
|
||||
`/api/partner-units/${activeUnitID}/members/${userID}/role`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ unit_role: role }),
|
||||
},
|
||||
);
|
||||
sel.disabled = false;
|
||||
if (!resp.ok) {
|
||||
if (prior !== undefined) sel.value = prior;
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Rolle konnte nicht gespeichert werden.", true);
|
||||
return;
|
||||
}
|
||||
await loadUnits();
|
||||
renderMemberList();
|
||||
render();
|
||||
showFeedback(tDyn("admin.partner_units.feedback.role_updated") || "Rolle aktualisiert.", false);
|
||||
}
|
||||
|
||||
function wireSuggestions(): void {
|
||||
|
||||
@@ -8,6 +8,10 @@ interface User {
|
||||
office: string;
|
||||
additional_offices?: string[];
|
||||
job_title: string | null;
|
||||
// t-paliad-148: structured firm-tier (partner/of_counsel/associate/
|
||||
// senior_pa/pa/paralegal). NULL = external. Editable via the
|
||||
// admin-team Profession column.
|
||||
profession?: string | null;
|
||||
global_role: string;
|
||||
lang: string;
|
||||
reminder_morning_time?: string;
|
||||
@@ -16,6 +20,15 @@ interface User {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PROFESSION_VALUES = [
|
||||
"partner",
|
||||
"of_counsel",
|
||||
"associate",
|
||||
"senior_pa",
|
||||
"pa",
|
||||
"paralegal",
|
||||
];
|
||||
|
||||
interface Office {
|
||||
key: string;
|
||||
label_de: string;
|
||||
@@ -180,6 +193,26 @@ function permissionEditor(u: User): string {
|
||||
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
|
||||
}
|
||||
|
||||
function professionLabel(p: string | null | undefined): string {
|
||||
if (!p) return "";
|
||||
return tDyn(`projects.team.profession.${p}`) || p;
|
||||
}
|
||||
|
||||
function professionCell(u: User): string {
|
||||
if (!u.profession) {
|
||||
return `<span class="admin-team-muted" title="${esc(t("admin.team.col.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis")}">${esc(t("projects.team.profession.none") || "(extern)")}</span>`;
|
||||
}
|
||||
return `<span class="projekt-team-profession">${esc(professionLabel(u.profession))}</span>`;
|
||||
}
|
||||
|
||||
function professionEditor(u: User): string {
|
||||
const noneOpt = `<option value=""${!u.profession ? " selected" : ""}>${esc(t("admin.team.col.profession.none") || "(extern)")}</option>`;
|
||||
const opts = PROFESSION_VALUES.map(
|
||||
(p) => `<option value="${esc(p)}"${u.profession === p ? " selected" : ""}>${esc(professionLabel(p))}</option>`,
|
||||
).join("");
|
||||
return `<select class="admin-team-input" data-field="profession">${noneOpt}${opts}</select>`;
|
||||
}
|
||||
|
||||
function renderRow(u: User): string {
|
||||
if (editingId === u.id) return renderEditRow(u);
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
@@ -190,6 +223,7 @@ function renderRow(u: User): string {
|
||||
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
|
||||
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${professionCell(u)}</td>
|
||||
<td>${permissionCell(u)}</td>
|
||||
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${esc(u.lang.toUpperCase())}</td>
|
||||
@@ -214,6 +248,7 @@ function renderEditRow(u: User): string {
|
||||
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
|
||||
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
|
||||
</td>
|
||||
<td>${professionEditor(u)}</td>
|
||||
<td>${permissionEditor(u)}</td>
|
||||
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
|
||||
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
|
||||
|
||||
236
frontend/src/client/agenda-render.ts
Normal file
236
frontend/src/client/agenda-render.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// Shared agenda timeline rendering primitives. The standalone /agenda page
|
||||
// (client/agenda.ts) and the inline Agenda section on /dashboard
|
||||
// (client/dashboard.ts) both render the same item shape; this module is
|
||||
// the single source of truth for how an AgendaItem turns into HTML.
|
||||
//
|
||||
// Stateless. The caller fetches /api/agenda, hands the items to
|
||||
// renderAgendaTimeline(), and drops the resulting HTML into a container.
|
||||
// i18n labels are resolved at render time via t/tDyn from ./i18n, so the
|
||||
// onLangChange hook on the calling page re-renders correctly.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. Kept in sync with the
|
||||
// matching constants in events.ts / inbox.ts / dashboard.ts.
|
||||
const APPROVAL_PILL_GLYPH = "👀";
|
||||
// Sparkle glyph ✨ for Paliadin-drafted pending rows (t-paliad-161).
|
||||
// Renders alongside (not in place of) 👀 — orthogonal axes.
|
||||
const AGENT_PILL_GLYPH = "✨";
|
||||
|
||||
export type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
|
||||
export type AgendaType = "deadline" | "appointment";
|
||||
|
||||
export interface AgendaItem {
|
||||
id: string;
|
||||
type: AgendaType;
|
||||
title: string;
|
||||
date: string; // ISO 8601
|
||||
end_at?: string | null;
|
||||
due_date?: string | null; // YYYY-MM-DD (deadlines only)
|
||||
status?: string | null;
|
||||
location?: string | null;
|
||||
appointment_type?: string | null;
|
||||
urgency: Urgency;
|
||||
project_id?: string | null;
|
||||
project_title?: string | null;
|
||||
project_type?: string | null;
|
||||
project_reference?: string | null;
|
||||
approval_status?: "approved" | "pending" | "legacy" | null;
|
||||
requester_kind?: "user" | "agent" | null;
|
||||
}
|
||||
|
||||
interface DayBucket {
|
||||
dayKey: string;
|
||||
day: Date;
|
||||
items: AgendaItem[];
|
||||
}
|
||||
|
||||
// Render a full timeline (day buckets with items) for an array of agenda
|
||||
// items. Returns a single HTML string ready to assign via innerHTML.
|
||||
// The empty case returns an empty string — callers that want an empty-
|
||||
// state UI handle it themselves (different copy on /agenda vs the
|
||||
// dashboard inline slot).
|
||||
export function renderAgendaTimeline(items: AgendaItem[]): string {
|
||||
if (!items.length) return "";
|
||||
const buckets = groupByDay(items);
|
||||
return buckets.map((b) => renderDay(b)).join("");
|
||||
}
|
||||
|
||||
function groupByDay(items: AgendaItem[]): DayBucket[] {
|
||||
const map = new Map<string, DayBucket>();
|
||||
for (const it of items) {
|
||||
const d = new Date(it.date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
const key = toLocalDayKey(d);
|
||||
let b = map.get(key);
|
||||
if (!b) {
|
||||
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
|
||||
map.set(key, b);
|
||||
}
|
||||
b.items.push(it);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||
}
|
||||
|
||||
function renderDay(bucket: DayBucket): string {
|
||||
const expected = expectedUrgency(bucket.day);
|
||||
return `<section class="agenda-day">
|
||||
<h2 class="agenda-day-heading">
|
||||
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
|
||||
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
|
||||
</h2>
|
||||
<ul class="agenda-items">
|
||||
${bucket.items.map((it) => renderItem(it, expected)).join("")}
|
||||
</ul>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// F-32: an item's urgency tag duplicates the day-bucket heading in the
|
||||
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
|
||||
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
|
||||
// "Überfällig" deadline that lands in today's bucket because of a filter
|
||||
// quirk. expectedUrgency mirrors the server's bucketing rule against the
|
||||
// bucket's day.
|
||||
function expectedUrgency(day: Date): Urgency {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) return "overdue";
|
||||
if (diff === 0) return "today";
|
||||
if (diff === 1) return "tomorrow";
|
||||
if (diff <= 6) return "this_week";
|
||||
return "later";
|
||||
}
|
||||
|
||||
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||||
const urgencyClass = `agenda-item-${it.urgency}`;
|
||||
const typeClass = `agenda-item-type-${it.type}`;
|
||||
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
|
||||
const detailHref = itemDetailHref(it);
|
||||
const project = it.project_id
|
||||
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
|
||||
: "";
|
||||
const pendingLabel = it.approval_status === "pending" ? tDyn("approvals.pending_update.label") : "";
|
||||
const pendingPill = it.approval_status === "pending"
|
||||
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
const agentLabel = tDyn("approvals.agent.label");
|
||||
const agentPill = it.approval_status === "pending" && it.requester_kind === "agent"
|
||||
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
|
||||
const timePart = it.type === "appointment"
|
||||
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
|
||||
: "";
|
||||
const urgencyTag = it.urgency !== bucketUrgency
|
||||
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
|
||||
: "";
|
||||
const locationPart = it.type === "appointment" && it.location
|
||||
? `<span class="agenda-item-location">${esc(it.location)}</span>`
|
||||
: "";
|
||||
const typeLabelKey = it.type === "deadline"
|
||||
? "agenda.label.deadline"
|
||||
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
|
||||
const typeLabel = tDyn(typeLabelKey);
|
||||
|
||||
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
|
||||
<a class="agenda-item-link" href="${esc(detailHref)}">
|
||||
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
|
||||
<span class="agenda-item-main">
|
||||
<span class="agenda-item-headline">
|
||||
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
|
||||
<span class="agenda-item-title">${esc(it.title)}</span>
|
||||
${pendingPill}
|
||||
${agentPill}
|
||||
</span>
|
||||
<span class="agenda-item-sub">
|
||||
${project}
|
||||
${timePart}
|
||||
${locationPart}
|
||||
</span>
|
||||
</span>
|
||||
<span class="agenda-item-meta">
|
||||
${urgencyTag}
|
||||
</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function itemDetailHref(it: AgendaItem): string {
|
||||
return it.type === "deadline"
|
||||
? `/deadlines/${encodeURIComponent(it.id)}`
|
||||
: `/appointments/${encodeURIComponent(it.id)}`;
|
||||
}
|
||||
|
||||
function formatProjectLabel(it: AgendaItem): string {
|
||||
const ref = it.project_reference ? `${it.project_reference} · ` : "";
|
||||
const title = it.project_title || "";
|
||||
return `${ref}${title}`.trim();
|
||||
}
|
||||
|
||||
function formatAppointmentTime(it: AgendaItem): string {
|
||||
const start = new Date(it.date);
|
||||
if (isNaN(start.getTime())) return "";
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
if (!it.end_at) return startStr;
|
||||
const end = new Date(it.end_at);
|
||||
if (isNaN(end.getTime())) return startStr;
|
||||
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${startStr}–${endStr}`;
|
||||
}
|
||||
|
||||
function relativeDayLabel(day: Date): string {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) {
|
||||
const n = Math.abs(diff);
|
||||
return getLang() === "de"
|
||||
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
|
||||
: (n === 1 ? "Yesterday" : `${n} days ago`);
|
||||
}
|
||||
if (diff === 0) return t("agenda.day.today");
|
||||
if (diff === 1) return t("agenda.day.tomorrow");
|
||||
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
|
||||
}
|
||||
|
||||
function fullDateLabel(day: Date): string {
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return day.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toISODate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function toLocalDayKey(d: Date): string {
|
||||
return toISODate(d);
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function deadlineIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
|
||||
}
|
||||
|
||||
function appointmentIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
}
|
||||
@@ -1,30 +1,12 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
|
||||
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
||||
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
|
||||
type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
|
||||
type AgendaType = "deadline" | "appointment";
|
||||
type TypeFilter = "both" | "deadlines" | "appointments";
|
||||
|
||||
interface AgendaItem {
|
||||
id: string;
|
||||
type: AgendaType;
|
||||
title: string;
|
||||
date: string; // ISO 8601
|
||||
end_at?: string | null;
|
||||
due_date?: string | null; // YYYY-MM-DD (deadlines only)
|
||||
status?: string | null; // deadlines: pending/completed/...
|
||||
location?: string | null;
|
||||
appointment_type?: string | null;
|
||||
urgency: Urgency;
|
||||
project_id?: string | null;
|
||||
project_title?: string | null;
|
||||
project_type?: string | null; // client | litigation | patent | case | project
|
||||
project_reference?: string | null;
|
||||
}
|
||||
|
||||
interface AgendaPayload {
|
||||
items: AgendaItem[];
|
||||
from: string;
|
||||
@@ -212,152 +194,7 @@ function render(): void {
|
||||
}
|
||||
empty.style.display = "none";
|
||||
timeline.style.display = "";
|
||||
|
||||
const buckets = groupByDay(state.items);
|
||||
timeline.innerHTML = buckets.map((b) => renderDay(b)).join("");
|
||||
}
|
||||
|
||||
interface DayBucket {
|
||||
dayKey: string; // YYYY-MM-DD local
|
||||
day: Date;
|
||||
items: AgendaItem[];
|
||||
}
|
||||
|
||||
function groupByDay(items: AgendaItem[]): DayBucket[] {
|
||||
const map = new Map<string, DayBucket>();
|
||||
for (const it of items) {
|
||||
const d = new Date(it.date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
const key = toLocalDayKey(d);
|
||||
let b = map.get(key);
|
||||
if (!b) {
|
||||
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
|
||||
map.set(key, b);
|
||||
}
|
||||
b.items.push(it);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||
}
|
||||
|
||||
function renderDay(bucket: DayBucket): string {
|
||||
const expected = expectedUrgency(bucket.day);
|
||||
return `<section class="agenda-day">
|
||||
<h2 class="agenda-day-heading">
|
||||
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
|
||||
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
|
||||
</h2>
|
||||
<ul class="agenda-items">
|
||||
${bucket.items.map((it) => renderItem(it, expected)).join("")}
|
||||
</ul>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// F-32: an item's urgency tag duplicates the day-bucket heading in the
|
||||
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
|
||||
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
|
||||
// "Überfällig" deadline that lands in today's bucket because of a filter
|
||||
// quirk. expectedUrgency mirrors the server's bucketing rule against the
|
||||
// bucket's day.
|
||||
function expectedUrgency(day: Date): Urgency {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) return "overdue";
|
||||
if (diff === 0) return "today";
|
||||
if (diff === 1) return "tomorrow";
|
||||
if (diff <= 6) return "this_week";
|
||||
return "later";
|
||||
}
|
||||
|
||||
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||||
const urgencyClass = `agenda-item-${it.urgency}`;
|
||||
const typeClass = `agenda-item-type-${it.type}`;
|
||||
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
|
||||
const detailHref = itemDetailHref(it);
|
||||
const project = it.project_id
|
||||
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
|
||||
: "";
|
||||
|
||||
const timePart = it.type === "appointment"
|
||||
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
|
||||
: "";
|
||||
const urgencyTag = it.urgency !== bucketUrgency
|
||||
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
|
||||
: "";
|
||||
const locationPart = it.type === "appointment" && it.location
|
||||
? `<span class="agenda-item-location">${esc(it.location)}</span>`
|
||||
: "";
|
||||
const typeLabelKey = it.type === "deadline"
|
||||
? "agenda.label.deadline"
|
||||
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
|
||||
const typeLabel = tDyn(typeLabelKey);
|
||||
|
||||
return `<li class="agenda-item ${typeClass} ${urgencyClass}">
|
||||
<a class="agenda-item-link" href="${esc(detailHref)}">
|
||||
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
|
||||
<span class="agenda-item-main">
|
||||
<span class="agenda-item-headline">
|
||||
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
|
||||
<span class="agenda-item-title">${esc(it.title)}</span>
|
||||
</span>
|
||||
<span class="agenda-item-sub">
|
||||
${project}
|
||||
${timePart}
|
||||
${locationPart}
|
||||
</span>
|
||||
</span>
|
||||
<span class="agenda-item-meta">
|
||||
${urgencyTag}
|
||||
</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function itemDetailHref(it: AgendaItem): string {
|
||||
return it.type === "deadline"
|
||||
? `/deadlines/${encodeURIComponent(it.id)}`
|
||||
: `/appointments/${encodeURIComponent(it.id)}`;
|
||||
}
|
||||
|
||||
function formatProjectLabel(it: AgendaItem): string {
|
||||
const ref = it.project_reference ? `${it.project_reference} · ` : "";
|
||||
const title = it.project_title || "";
|
||||
return `${ref}${title}`.trim();
|
||||
}
|
||||
|
||||
function formatAppointmentTime(it: AgendaItem): string {
|
||||
const start = new Date(it.date);
|
||||
if (isNaN(start.getTime())) return "";
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
if (!it.end_at) return startStr;
|
||||
const end = new Date(it.end_at);
|
||||
if (isNaN(end.getTime())) return startStr;
|
||||
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${startStr}–${endStr}`;
|
||||
}
|
||||
|
||||
function relativeDayLabel(day: Date): string {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) {
|
||||
const n = Math.abs(diff);
|
||||
return getLang() === "de"
|
||||
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
|
||||
: (n === 1 ? "Yesterday" : `${n} days ago`);
|
||||
}
|
||||
if (diff === 0) return t("agenda.day.today");
|
||||
if (diff === 1) return t("agenda.day.tomorrow");
|
||||
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
|
||||
}
|
||||
|
||||
function fullDateLabel(day: Date): string {
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return day.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
timeline.innerHTML = renderAgendaTimeline(state.items);
|
||||
}
|
||||
|
||||
function syncChips(): void {
|
||||
@@ -387,21 +224,3 @@ function toISODate(d: Date): string {
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function toLocalDayKey(d: Date): string {
|
||||
return toISODate(d);
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function deadlineIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
|
||||
}
|
||||
|
||||
function appointmentIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ interface Appointment {
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
created_by?: string;
|
||||
// t-paliad-138 + t-paliad-160 — pending-approval surface.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
pending_request_id?: string | null;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
status: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
@@ -25,6 +41,8 @@ interface Project {
|
||||
let appointment: Appointment | null = null;
|
||||
let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -89,6 +107,31 @@ async function loadAllProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// loadPendingRequest mirrors deadlines-detail.ts (t-paliad-160 §C+E):
|
||||
// pull the in-flight approval_request when the entity is pending so the
|
||||
// badge tooltip + the Withdraw button can be wired correctly.
|
||||
async function loadPendingRequest(): Promise<void> {
|
||||
pendingRequest = null;
|
||||
if (!appointment || appointment.approval_status !== "pending" || !appointment.pending_request_id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${appointment.pending_request_id}`);
|
||||
if (resp.ok) pendingRequest = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectPicker() {
|
||||
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
@@ -133,6 +176,44 @@ function renderHeader() {
|
||||
} else {
|
||||
projectRow.style.display = "none";
|
||||
}
|
||||
|
||||
// t-paliad-160 §C+E — pending-approval badge + withdraw + freeze controls.
|
||||
const isPending = appointment.approval_status === "pending";
|
||||
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
|
||||
const apBadge = document.getElementById("appointment-pending-approval-badge") as HTMLElement | null;
|
||||
if (apBadge) {
|
||||
if (isPending) {
|
||||
apBadge.style.display = "";
|
||||
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
|
||||
apBadge.textContent = labelDe;
|
||||
if (pendingRequest) {
|
||||
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
|
||||
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
|
||||
const when = fmtDateTime(pendingRequest.requested_at);
|
||||
apBadge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
|
||||
} else {
|
||||
apBadge.title = labelDe;
|
||||
}
|
||||
} else {
|
||||
apBadge.style.display = "none";
|
||||
apBadge.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
const withdrawBtn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (withdrawBtn) {
|
||||
withdrawBtn.style.display = (isPending && isRequester) ? "" : "none";
|
||||
withdrawBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
}
|
||||
|
||||
function fillEditForm() {
|
||||
@@ -219,9 +300,9 @@ async function deleteAppointment() {
|
||||
if (resp.ok || resp.status === 204) {
|
||||
window.location.href = "/events?type=appointment";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = data.error || t("appointments.error.generic");
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} catch {
|
||||
@@ -231,6 +312,40 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = data.message || data.error || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = (t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const id = parseAppointmentID();
|
||||
const loading = document.getElementById("appointment-loading")!;
|
||||
@@ -250,6 +365,8 @@ async function main() {
|
||||
await Promise.all([
|
||||
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
|
||||
loadAllProjects(),
|
||||
loadMe(),
|
||||
loadPendingRequest(),
|
||||
]);
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
@@ -259,6 +376,8 @@ async function main() {
|
||||
|
||||
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
|
||||
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
|
||||
const withdrawBtn = document.getElementById("appointment-withdraw-btn");
|
||||
if (withdrawBtn) withdrawBtn.addEventListener("click", () => void withdrawAppointmentRequest());
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
if (notes) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
@@ -107,6 +107,53 @@ async function submitForm(ev: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-154 — form-time 4-eye hint, mirroring deadlines-new.ts.
|
||||
async function refreshApprovalHint(): Promise<void> {
|
||||
const hint = document.getElementById("appointment-approval-hint");
|
||||
const text = document.getElementById("appointment-approval-hint-text");
|
||||
if (!hint || !text) return;
|
||||
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement | null)?.value || "";
|
||||
if (!projectID) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=appointment&lifecycle=create`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar — read requires_approval + min_role.
|
||||
// Fall back to the legacy required_role mirror (M1 dual-read window
|
||||
// only — drops in M2).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
required_role?: string | null;
|
||||
source?: string | null;
|
||||
source_name?: string | null;
|
||||
};
|
||||
const role = eff.min_role || eff.required_role || null;
|
||||
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
|
||||
if (!required || !role) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
|
||||
const sourceLabel = eff.source_name
|
||||
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
|
||||
: "";
|
||||
text.textContent = (t("appointments.form.approval_hint") || "4-Augen-Prüfung erforderlich")
|
||||
+ ` · ${roleLabel}${sourceLabel}`;
|
||||
hint.style.display = "";
|
||||
} catch {
|
||||
hint.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -114,4 +161,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
populateProjects();
|
||||
preFillStart();
|
||||
document.getElementById("appointment-new-form")!.addEventListener("submit", submitForm);
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("appointment-project")?.addEventListener("change", () => {
|
||||
void refreshApprovalHint();
|
||||
});
|
||||
});
|
||||
|
||||
300
frontend/src/client/broadcast.ts
Normal file
300
frontend/src/client/broadcast.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
|
||||
//
|
||||
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
||||
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
||||
// collects subject + body + (optional) template and posts to
|
||||
// /api/team/broadcast. On success it shows a per-recipient send report
|
||||
// and closes.
|
||||
//
|
||||
// Per-recipient privacy: each member receives their own envelope. The
|
||||
// modal lists every addressee so the sender knows exactly who will be
|
||||
// mailed; there is no surprise to-line.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface BroadcastRecipient {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
first_name: string;
|
||||
role_on_project: string;
|
||||
}
|
||||
|
||||
export interface OpenBroadcastModalArgs {
|
||||
recipients: BroadcastRecipient[];
|
||||
projectID?: string | null;
|
||||
projectIDs?: string[];
|
||||
offices?: string[];
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
interface EmailTemplateOption {
|
||||
key: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const RECIPIENT_CAP = 100;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// firstName extracts the first whitespace-separated token from a display
|
||||
// name. "Anna von Beispiel" → "Anna". Empty input → "".
|
||||
export function firstName(displayName: string): string {
|
||||
return displayName.trim().split(/\s+/)[0] ?? "";
|
||||
}
|
||||
|
||||
// buildMailtoHref produces a `mailto:` URL with every recipient queued
|
||||
// in the To: field, comma-separated per RFC 6068. The `?` form is
|
||||
// preserved as a future hook for default subject/body — kept empty here
|
||||
// so users compose their own message in their mail client. Empty input
|
||||
// returns "mailto:" so the button still renders without a JS error.
|
||||
export function buildMailtoHref(recipients: BroadcastRecipient[]): string {
|
||||
const addrs = recipients
|
||||
.map((r) => r.email.trim())
|
||||
.filter((e) => e.length > 0)
|
||||
.map((e) => encodeURIComponent(e));
|
||||
if (!addrs.length) return "mailto:";
|
||||
return `mailto:${addrs.join(",")}`;
|
||||
}
|
||||
|
||||
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
||||
if (!args.recipients.length) {
|
||||
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
|
||||
return;
|
||||
}
|
||||
if (args.recipients.length > RECIPIENT_CAP) {
|
||||
alert(
|
||||
(t("team.broadcast.error.too_many") || "Empfängerlimit ({cap}) überschritten.").replace(
|
||||
"{cap}",
|
||||
String(RECIPIENT_CAP),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing modal? Remove. Avoids stacking on rapid double-click.
|
||||
document.getElementById("broadcast-modal")?.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "broadcast-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
document.addEventListener("keydown", function escClose(e) {
|
||||
if (e.key === "Escape") {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", escClose);
|
||||
}
|
||||
});
|
||||
|
||||
// Recipient toggle
|
||||
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown
|
||||
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
|
||||
// Submit
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(form, overlay, args);
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
const count = args.recipients.length;
|
||||
const previewItems = args.recipients
|
||||
.slice(0, 5)
|
||||
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
||||
.join(", ");
|
||||
const more = count > 5 ? ` +${count - 5}` : "";
|
||||
|
||||
const fullList = args.recipients
|
||||
.map(
|
||||
(r) =>
|
||||
`<li><span class="broadcast-recip-name">${esc(r.display_name)}</span> <span class="broadcast-recip-email"><${esc(r.email)}></span>${
|
||||
r.role_on_project ? ` <span class="broadcast-recip-role">${esc(r.role_on_project)}</span>` : ""
|
||||
}</li>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
|
||||
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">×</button>
|
||||
</header>
|
||||
<form data-broadcast-form>
|
||||
<div class="modal-body">
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
|
||||
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
|
||||
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
errEl?.classList.add("hidden");
|
||||
okEl?.classList.add("hidden");
|
||||
|
||||
if (!subject) {
|
||||
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
|
||||
}
|
||||
|
||||
const recipientFilter: Record<string, unknown> = {};
|
||||
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
||||
if (args.projectID) recipientFilter.project_id = args.projectID;
|
||||
if (args.offices?.length) recipientFilter.offices = args.offices;
|
||||
if (args.roles?.length) recipientFilter.roles = args.roles;
|
||||
|
||||
const lang = (document.documentElement.lang === "en" ? "en" : "de");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/team/broadcast", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
project_id: args.projectID ?? null,
|
||||
subject,
|
||||
body,
|
||||
template_key: templateKey || undefined,
|
||||
lang,
|
||||
recipient_filter: recipientFilter,
|
||||
recipients: args.recipients,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
||||
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const report = (await res.json()) as { sent: number; failed: number; total: number };
|
||||
if (okEl) {
|
||||
okEl.classList.remove("hidden");
|
||||
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
||||
okEl.textContent = tpl
|
||||
.replace("{sent}", String(report.sent))
|
||||
.replace("{total}", String(report.total))
|
||||
.replace("{failed}", String(report.failed));
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
|
||||
}
|
||||
setTimeout(() => overlay.remove(), 2500);
|
||||
} catch (e) {
|
||||
showError(errEl, String(e));
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showError(el: HTMLDivElement | null | undefined, msg: string) {
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// stripGoTemplate is best-effort: existing email templates carry
|
||||
// `{{define "content"}}` wrappers and Go-template branches the broadcast
|
||||
// compose form can't honour. The bulk-send pipeline expects plain
|
||||
// Markdown + the placeholder set documented in the modal, so we strip
|
||||
// the template directives before populating the textarea. Senders can
|
||||
// still edit further.
|
||||
function stripGoTemplate(src: string): string {
|
||||
return src
|
||||
.replace(/\{\{\s*(define|end|block|if|else|range|with)\b[^}]*\}\}/g, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
||||
|
||||
interface DashboardUser {
|
||||
id: string;
|
||||
@@ -73,7 +74,13 @@ declare global {
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
// 30-day look-ahead matches the agenda.tsx default chip and the server's
|
||||
// default `to=today+30d` window — keeps the inline agenda visually
|
||||
// consistent with /agenda when users follow the "full agenda" link.
|
||||
const AGENDA_LOOKAHEAD_DAYS = 30;
|
||||
const COLLAPSE_KEY_PREFIX = "paliad:dashboard:collapse:";
|
||||
let data: DashboardData | null = null;
|
||||
let agendaItems: AgendaItem[] | null = null;
|
||||
|
||||
async function loadDashboard(): Promise<void> {
|
||||
const unavailable = document.getElementById("dashboard-unavailable")!;
|
||||
@@ -101,6 +108,7 @@ function render(): void {
|
||||
renderMatters(data.matter_summary);
|
||||
renderDeadlines(data.upcoming_deadlines);
|
||||
renderAppointments(data.upcoming_appointments);
|
||||
renderAgenda();
|
||||
renderActivity(data.recent_activity);
|
||||
toggleOnboardingHint(data.user);
|
||||
}
|
||||
@@ -307,6 +315,130 @@ function activityHref(e: ActivityEntry): string {
|
||||
return `/projects/${e.project_id}`;
|
||||
}
|
||||
|
||||
// Render the inline Agenda section. Items are fetched once on mount via
|
||||
// loadAgenda(); subsequent re-renders (lang change, dashboard poll) reuse
|
||||
// the cached array. The dashboard inline agenda is read-only — no chip
|
||||
// filters, default 30-day window — see CollapsibleSection in
|
||||
// dashboard.tsx for the surrounding shell.
|
||||
function renderAgenda(): void {
|
||||
const timeline = document.getElementById("dashboard-agenda-timeline");
|
||||
const empty = document.getElementById("dashboard-agenda-empty");
|
||||
if (!timeline || !empty) return;
|
||||
if (agendaItems === null) {
|
||||
// Items haven't landed yet — keep the timeline blank but hide empty
|
||||
// hint so we don't flash "nothing due" before the fetch resolves.
|
||||
timeline.innerHTML = "";
|
||||
timeline.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!agendaItems.length) {
|
||||
timeline.innerHTML = "";
|
||||
timeline.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
timeline.style.display = "";
|
||||
timeline.innerHTML = renderAgendaTimeline(agendaItems);
|
||||
}
|
||||
|
||||
async function loadAgenda(): Promise<void> {
|
||||
const from = toAgendaDate(startOfToday());
|
||||
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 1));
|
||||
try {
|
||||
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
|
||||
if (!resp.ok) {
|
||||
// Fail silently — the rest of the dashboard still loads. The
|
||||
// inline agenda is best-effort: a 503 (DB-less knowledge-platform
|
||||
// deploy) or 401 (session timed out, should be caught by the
|
||||
// page-level redirect) just leaves the section empty.
|
||||
agendaItems = [];
|
||||
renderAgenda();
|
||||
return;
|
||||
}
|
||||
agendaItems = (await resp.json()) as AgendaItem[];
|
||||
renderAgenda();
|
||||
} catch {
|
||||
agendaItems = [];
|
||||
renderAgenda();
|
||||
}
|
||||
}
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + days);
|
||||
return r;
|
||||
}
|
||||
|
||||
function toAgendaDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Wire collapsible-section toggles. Each .dashboard-section carries a
|
||||
// data-collapse-key and the SSR markup renders aria-expanded="true" so
|
||||
// unstyled fallback shows everything; here we restore persisted state and
|
||||
// attach click handlers. Persistence is per-section via localStorage —
|
||||
// keys live under paliad:dashboard:collapse:<section> per the brief.
|
||||
function initCollapsibleSections(): void {
|
||||
const sections = document.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]");
|
||||
sections.forEach((section) => {
|
||||
const key = section.dataset.collapseKey || "";
|
||||
if (!key) return;
|
||||
const stored = localStorage.getItem(COLLAPSE_KEY_PREFIX + key);
|
||||
const collapsed = stored === "true";
|
||||
applyCollapseState(section, collapsed);
|
||||
|
||||
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener("click", () => {
|
||||
const nowExpanded = section.getAttribute("aria-expanded") === "true";
|
||||
const nextCollapsed = nowExpanded; // expanded → collapsing
|
||||
applyCollapseState(section, nextCollapsed);
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY_PREFIX + key, String(nextCollapsed));
|
||||
} catch {
|
||||
// localStorage may be full or disabled (Safari private mode);
|
||||
// collapse still works for the current page life. Silent.
|
||||
}
|
||||
});
|
||||
});
|
||||
// Re-localise the toggle aria-labels on language switch so screen
|
||||
// readers always read the current language. The visible heading text
|
||||
// is handled by the i18n applyTranslations pass already.
|
||||
syncCollapseAriaLabels();
|
||||
}
|
||||
|
||||
function applyCollapseState(section: HTMLElement, collapsed: boolean): void {
|
||||
section.setAttribute("aria-expanded", String(!collapsed));
|
||||
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
|
||||
if (toggle) {
|
||||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
toggle.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? t("dashboard.section.expand") : t("dashboard.section.collapse"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function syncCollapseAriaLabels(): void {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]")
|
||||
.forEach((section) => {
|
||||
const collapsed = section.getAttribute("aria-expanded") !== "true";
|
||||
applyCollapseState(section, collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOnboardingHint(user: DashboardUser | null): void {
|
||||
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
|
||||
// already redirects users without a paliad.users row to /onboarding before
|
||||
@@ -369,15 +501,28 @@ function escAttr(s: string): string {
|
||||
function schedulePolling(): void {
|
||||
// Refresh the payload every minute so open dashboards stay current when
|
||||
// teammates create Akten/Fristen. Uses the JSON endpoint — no page reload.
|
||||
// The inline agenda is refreshed on the same cadence to stay in sync
|
||||
// with the deadlines/appointments rails above it.
|
||||
window.setInterval(() => {
|
||||
void loadDashboard();
|
||||
void loadAgenda();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(render);
|
||||
initCollapsibleSections();
|
||||
onLangChange(() => {
|
||||
render();
|
||||
syncCollapseAriaLabels();
|
||||
});
|
||||
|
||||
// Inline agenda fetch is independent of the main dashboard payload.
|
||||
// Kicked off in parallel so the agenda section paints as soon as the
|
||||
// /api/agenda response lands instead of waiting on the dashboard
|
||||
// payload poll.
|
||||
void loadAgenda();
|
||||
|
||||
const inlined = window.__PALIAD_DASHBOARD__;
|
||||
if (inlined !== undefined) {
|
||||
|
||||
@@ -24,6 +24,20 @@ interface Deadline {
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
event_type_ids?: string[];
|
||||
// t-paliad-138 + t-paliad-160. approval_status='pending' means an
|
||||
// approval_request is in flight; pending_request_id resolves to it
|
||||
// and the controls flip to a withdraw affordance for the requester.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
pending_request_id?: string | null;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
status: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,6 +68,7 @@ let project: Project | null = null;
|
||||
let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -170,6 +185,23 @@ async function loadMe() {
|
||||
}
|
||||
}
|
||||
|
||||
// loadPendingRequest hydrates the in-flight approval_request when the
|
||||
// entity carries approval_status='pending'. Used to populate the badge
|
||||
// tooltip + decide whether to show the Withdraw button (only the
|
||||
// requester can withdraw).
|
||||
async function loadPendingRequest(): Promise<void> {
|
||||
pendingRequest = null;
|
||||
if (!deadline || deadline.approval_status !== "pending" || !deadline.pending_request_id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${deadline.pending_request_id}`);
|
||||
if (resp.ok) pendingRequest = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal — badge still renders without the tooltip details */
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!deadline) return;
|
||||
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
|
||||
@@ -249,19 +281,49 @@ function render() {
|
||||
|
||||
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
|
||||
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
|
||||
const withdrawBtn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement;
|
||||
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
|
||||
const badge = document.getElementById("deadline-pending-approval-badge") as HTMLElement | null;
|
||||
|
||||
// t-paliad-160 §C+E — approval_status='pending' freezes the action
|
||||
// controls and surfaces the badge + a Withdraw button (visible only to
|
||||
// the requester). Other authenticated viewers see only the badge.
|
||||
const isPending = deadline.approval_status === "pending";
|
||||
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
|
||||
|
||||
if (badge) {
|
||||
if (isPending) {
|
||||
badge.style.display = "";
|
||||
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
|
||||
badge.textContent = labelDe;
|
||||
// Tooltip carries requester + required_role + age (best-effort).
|
||||
if (pendingRequest) {
|
||||
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
|
||||
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
|
||||
const when = fmtDateTime(pendingRequest.requested_at);
|
||||
badge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
|
||||
} else {
|
||||
badge.title = labelDe;
|
||||
}
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
badge.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons.
|
||||
if (deadline.status === "completed") {
|
||||
completeBtn.style.display = "none";
|
||||
// Reopen is admin-gated server-side; the button is shown for global
|
||||
// admins/partners here as a client-side hint. Project leads who lack a
|
||||
// global admin/partner role won't see the inline button — they get a 403
|
||||
// only if they try, but the button itself stays hidden. They can still
|
||||
// PATCH the endpoint directly.
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
if (me && (me.global_role === "global_admin") && !isPending) {
|
||||
reopenBtn.style.display = "";
|
||||
reopenBtn.disabled = false;
|
||||
} else {
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
} else if (isPending) {
|
||||
// Lifecycle frozen — server returns 409 to anyone who tries.
|
||||
completeBtn.style.display = "none";
|
||||
reopenBtn.style.display = "none";
|
||||
} else {
|
||||
completeBtn.style.display = "";
|
||||
completeBtn.disabled = false;
|
||||
@@ -269,8 +331,22 @@ function render() {
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// Edit button: hidden during pending so users don't fight a 409.
|
||||
if (editBtn) editBtn.style.display = isPending ? "none" : "";
|
||||
|
||||
// Withdraw button: visible only when caller is the requester of the
|
||||
// in-flight request.
|
||||
if (withdrawBtn) {
|
||||
if (isPending && isRequester) {
|
||||
withdrawBtn.style.display = "";
|
||||
withdrawBtn.disabled = false;
|
||||
} else {
|
||||
withdrawBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
if (me && (me.global_role === "global_admin") && !isPending) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
@@ -377,6 +453,25 @@ function initComplete() {
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
deadline = await resp.json();
|
||||
// The complete may have created an approval_request rather than
|
||||
// completed the deadline outright (4-eye-required). Re-fetch the
|
||||
// entity + pending request to surface the right state.
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else if (resp.status === 409) {
|
||||
// The handler returns the t-paliad-160 §B body shape. Surface
|
||||
// the human message and refresh state — likely a concurrent
|
||||
// request was already in flight.
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && body.message) || t("approvals.error.awaiting_approval") || "Diese Anforderung wartet auf Genehmigung.";
|
||||
window.alert(msg);
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) {
|
||||
deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
}
|
||||
render();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -406,6 +501,48 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error)) || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
|
||||
window.alert(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
window.alert((t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDelete() {
|
||||
const btn = document.getElementById("deadline-delete-btn")!;
|
||||
const modal = document.getElementById("deadline-delete-modal")!;
|
||||
@@ -455,7 +592,7 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
@@ -485,6 +622,7 @@ async function main() {
|
||||
initEdit();
|
||||
initComplete();
|
||||
initReopen();
|
||||
initWithdraw();
|
||||
initDelete();
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypePicker, type PickerHandle } from "./event-types";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
eventTypeLabel,
|
||||
fetchEventTypes,
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -19,8 +32,22 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
|
||||
let preselectedProjectID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -71,6 +98,7 @@ async function loadRules() {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
@@ -85,6 +113,93 @@ async function loadRules() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
@@ -171,6 +286,54 @@ async function loadMe() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-154 — fetch the effective approval policy for (project,
|
||||
// deadline, create) and reveal the form-time hint when it applies.
|
||||
// Hidden when no policy applies. Re-runs on project change so the hint
|
||||
// updates if the user picks a different project mid-form.
|
||||
async function refreshApprovalHint(): Promise<void> {
|
||||
const hint = document.getElementById("deadline-approval-hint");
|
||||
const text = document.getElementById("deadline-approval-hint-text");
|
||||
if (!hint || !text) return;
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
if (!projectID) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=deadline&lifecycle=create`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
required_role?: string | null;
|
||||
source?: string | null;
|
||||
source_name?: string | null;
|
||||
};
|
||||
const role = eff.min_role || eff.required_role || null;
|
||||
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
|
||||
if (!required || !role) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
|
||||
const sourceLabel = eff.source_name
|
||||
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
|
||||
: "";
|
||||
text.textContent = (t("deadlines.form.approval_hint") || "4-Augen-Prüfung erforderlich")
|
||||
+ ` · ${roleLabel}${sourceLabel}`;
|
||||
hint.style.display = "";
|
||||
} catch {
|
||||
hint.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -185,6 +348,39 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
void refreshApprovalHint();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,18 @@ import {
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
// pill markup trivially short and inherits the user's emoji font.
|
||||
const APPROVAL_PILL_GLYPH = "👀";
|
||||
|
||||
// Sparkle glyph ✨ inside .approval-pill--agent (t-paliad-161). Renders
|
||||
// next to (not in place of) 👀 when the pending row originated from a
|
||||
// Paliadin chat suggestion. The two glyphs are orthogonal: 👀 = "needs
|
||||
// approval", ✨ = "Paliadin drafted this". Both can coexist; either can
|
||||
// appear alone in future autopilot states.
|
||||
const AGENT_PILL_GLYPH = "✨";
|
||||
|
||||
// EventsPage shared client (t-paliad-110). Drives /deadlines and
|
||||
// /appointments off the same shell — the route handler injects
|
||||
// `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }`
|
||||
@@ -38,6 +50,12 @@ interface EventListItem {
|
||||
project_title?: string;
|
||||
project_type?: string;
|
||||
|
||||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
// t-paliad-161: when approval_status='pending', tells us whether the row
|
||||
// was drafted by a user or by Paliadin (✨ glyph). NULL when not pending.
|
||||
requester_kind?: "user" | "agent";
|
||||
|
||||
// deadline-only
|
||||
due_date?: string;
|
||||
status?: string;
|
||||
@@ -504,11 +522,28 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
|
||||
: "—";
|
||||
|
||||
return `<tr class="frist-row events-row events-row-${item.type}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||||
// Approval pending pill (t-paliad-138 / m's 2026-05-08 cosmetic ask).
|
||||
// Soft-tint the row + drop an eye-icon pill next to the title; hover
|
||||
// reveals the lifecycle label. Inbox surface shows the full detail.
|
||||
//
|
||||
// t-paliad-161 ✨: when the pending row came from a Paliadin
|
||||
// suggestion (requester_kind='agent'), drop a second pill next to 👀.
|
||||
// Two glyphs read together as "needs approval, drafted by Paliadin".
|
||||
const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const pendingLabel = item.approval_status === "pending" ? t("approvals.pending_update.label") : "";
|
||||
const pendingPill = item.approval_status === "pending"
|
||||
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
const agentLabel = t("approvals.agent.label");
|
||||
const agentPill = item.approval_status === "pending" && item.requester_kind === "agent"
|
||||
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
|
||||
return `<tr class="frist-row events-row events-row-${item.type}${pendingClass}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||||
<td class="frist-col-check">${checkCell}</td>
|
||||
<td class="events-col-row-type">${rowTypeChip(item)}</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(item.title)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}${agentPill ? " " + agentPill : ""}</td>
|
||||
<td class="frist-col-project">${projectCell}</td>
|
||||
<td class="frist-col-rule events-col-rule">${ruleLabel || "—"}</td>
|
||||
<td class="entity-col-event-type">${eventTypeCell || "—"}</td>
|
||||
|
||||
354
frontend/src/client/filter-bar/axes.ts
Normal file
354
frontend/src/client/filter-bar/axes.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
// Per-axis renderers for the FilterBar — t-paliad-163.
|
||||
//
|
||||
// Each axis is a small, self-contained render function that takes the
|
||||
// current BarState slice and a callback. The bar's mountFilterBar
|
||||
// composes them in the order declared on the surface.
|
||||
//
|
||||
// Reuses existing CSS classes wherever possible:
|
||||
// - .agenda-chip / .agenda-chip-active (chip cluster pattern)
|
||||
// - .filter-group (label + control wrapping)
|
||||
// - .akten-multi-trigger / .multi-anchor / .multi-panel
|
||||
//
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
import type { BarState, AxisKey } from "./types";
|
||||
|
||||
export interface AxisCtx {
|
||||
// Read the current value for this axis.
|
||||
get<K extends keyof BarState>(key: K): BarState[K];
|
||||
// Patch one or more axis values + trigger re-run.
|
||||
patch(delta: Partial<BarState>): void;
|
||||
}
|
||||
|
||||
// renderAxis returns the HTML element for a single axis. The bar's
|
||||
// mountFilterBar appends the result to its internal toolbar. Returns
|
||||
// null when the axis is ignored (e.g. surface didn't declare it).
|
||||
export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
|
||||
switch (axis) {
|
||||
case "time": return renderTimeAxis(ctx);
|
||||
case "project": return null; // populated lazily — see attachProjectAxis below
|
||||
case "personal_only": return renderPersonalOnlyAxis(ctx);
|
||||
case "approval_viewer_role": return renderApprovalRoleAxis(ctx);
|
||||
case "approval_status": return renderApprovalStatusAxis(ctx);
|
||||
case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
|
||||
case "deadline_status": return renderDeadlineStatusAxis(ctx);
|
||||
case "appointment_type": return renderAppointmentTypeAxis(ctx);
|
||||
case "shape": return renderShapeAxis(ctx);
|
||||
case "density": return renderDensityAxis(ctx);
|
||||
case "sort": return renderSortAxis(ctx);
|
||||
|
||||
// Per-source predicates that need their own widgets and a roundtrip
|
||||
// through fetched option lists. Phase 2+ will fill these in by
|
||||
// wiring the existing event-types / project-list components.
|
||||
case "deadline_event_type":
|
||||
case "project_event_kind":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIME_PRESETS: Array<{ value: BarState["time"] extends infer T ? (T extends { horizon: infer H } ? H : never) : never; key: I18nKey }> = [
|
||||
{ value: "next_7d", key: "views.bar.time.next_7d" },
|
||||
{ value: "next_30d", key: "views.bar.time.next_30d" },
|
||||
{ value: "next_90d", key: "views.bar.time.next_90d" },
|
||||
{ value: "past_30d", key: "views.bar.time.past_30d" },
|
||||
{ value: "any", key: "views.bar.time.any" },
|
||||
];
|
||||
|
||||
function renderTimeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.time");
|
||||
const row = chipRow();
|
||||
const current = ctx.get("time")?.horizon ?? "any";
|
||||
for (const preset of TIME_PRESETS) {
|
||||
const chip = chipBtn(t(preset.key), preset.value === current);
|
||||
chip.addEventListener("click", () => {
|
||||
if (preset.value === "any") {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset.value } });
|
||||
}
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Custom range — placeholder chip; opens a small popover with two
|
||||
// <input type="date"> in Phase 2. For Phase 1 we render the chip
|
||||
// disabled with a tooltip so the affordance is discoverable.
|
||||
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
|
||||
customChip.classList.add("filter-bar-chip-pending");
|
||||
customChip.title = t("views.bar.time.custom.coming_soon");
|
||||
customChip.disabled = true;
|
||||
row.appendChild(customChip);
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// personal_only — single chip (binary)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function renderPersonalOnlyAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.personal");
|
||||
const chip = chipBtn(t("views.bar.personal.on"), !!ctx.get("personal_only"));
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ personal_only: !ctx.get("personal_only") });
|
||||
});
|
||||
wrap.appendChild(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_viewer_role — chip cluster (3 mutually exclusive)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ROLES: Array<{ value: NonNullable<BarState["approval_viewer_role"]>; key: I18nKey }> = [
|
||||
{ value: "approver_eligible", key: "views.bar.approval_role.approver_eligible" },
|
||||
{ value: "self_requested", key: "views.bar.approval_role.self_requested" },
|
||||
{ value: "any_visible", key: "views.bar.approval_role.any_visible" },
|
||||
];
|
||||
|
||||
function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_role");
|
||||
const row = chipRow();
|
||||
// Default to "any_visible" so the surface lands on a populated view
|
||||
// for every user. The InboxSystemView's base spec also defaults here;
|
||||
// these two defaults must stay in sync — otherwise the chip and the
|
||||
// server narrow disagree on the empty URL.
|
||||
const current = ctx.get("approval_viewer_role") ?? "any_visible";
|
||||
for (const role of APPROVAL_ROLES) {
|
||||
const chip = chipBtn(t(role.key), role.value === current);
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ approval_viewer_role: role.value });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_status") ?? []);
|
||||
for (const status of APPROVAL_STATUSES) {
|
||||
const chip = chipBtn(t(status.key), current.has(status.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(status.value)) current.delete(status.value);
|
||||
else current.add(status.value);
|
||||
ctx.patch({ approval_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_entity_type — chip pair (multi-select; deadline / appointment)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ENTITY_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "deadline", key: "views.bar.approval_entity.deadline" },
|
||||
{ value: "appointment", key: "views.bar.approval_entity.appointment" },
|
||||
];
|
||||
|
||||
function renderApprovalEntityTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_entity");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_entity_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_entity_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_entity_type") ?? []);
|
||||
for (const ent of APPROVAL_ENTITY_TYPES) {
|
||||
const chip = chipBtn(t(ent.key), current.has(ent.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ent.value)) current.delete(ent.value);
|
||||
else current.add(ent.value);
|
||||
ctx.patch({ approval_entity_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// deadline_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DEADLINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.deadline_status.pending" },
|
||||
{ value: "completed", key: "views.bar.deadline_status.completed" },
|
||||
];
|
||||
|
||||
function renderDeadlineStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.deadline_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("deadline_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ deadline_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("deadline_status") ?? []);
|
||||
for (const s of DEADLINE_STATUSES) {
|
||||
const chip = chipBtn(t(s.key), current.has(s.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(s.value)) current.delete(s.value);
|
||||
else current.add(s.value);
|
||||
ctx.patch({ deadline_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// appointment_type — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPOINTMENT_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "hearing", key: "views.bar.appointment_type.hearing" },
|
||||
{ value: "meeting", key: "views.bar.appointment_type.meeting" },
|
||||
{ value: "consultation", key: "views.bar.appointment_type.consultation" },
|
||||
{ value: "deadline_hearing", key: "views.bar.appointment_type.deadline_hearing" },
|
||||
];
|
||||
|
||||
function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.appointment_type");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("appointment_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ appointment_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("appointment_type") ?? []);
|
||||
for (const ty of APPOINTMENT_TYPES) {
|
||||
const chip = chipBtn(t(ty.key), current.has(ty.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ty.value)) current.delete(ty.value);
|
||||
else current.add(ty.value);
|
||||
ctx.patch({ appointment_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shape — segmented control (list / cards / calendar)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SHAPES: Array<{ value: NonNullable<BarState["shape"]>; key: I18nKey }> = [
|
||||
{ value: "list", key: "views.bar.shape.list" },
|
||||
{ value: "cards", key: "views.bar.shape.cards" },
|
||||
{ value: "calendar", key: "views.bar.shape.calendar" },
|
||||
];
|
||||
|
||||
function renderShapeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.shape");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("shape");
|
||||
for (const sh of SHAPES) {
|
||||
const chip = chipBtn(t(sh.key), sh.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ shape: sh.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// density — segmented pair (comfortable / compact)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DENSITIES: Array<{ value: NonNullable<BarState["density"]>; key: I18nKey }> = [
|
||||
{ value: "comfortable", key: "views.bar.density.comfortable" },
|
||||
{ value: "compact", key: "views.bar.density.compact" },
|
||||
];
|
||||
|
||||
function renderDensityAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.density");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("density") ?? "comfortable";
|
||||
for (const d of DENSITIES) {
|
||||
const chip = chipBtn(t(d.key), d.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ density: d.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// sort — small <select>
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SORTS: Array<{ value: NonNullable<BarState["sort"]>; key: I18nKey }> = [
|
||||
{ value: "date_asc", key: "views.bar.sort.date_asc" },
|
||||
{ value: "date_desc", key: "views.bar.sort.date_desc" },
|
||||
];
|
||||
|
||||
function renderSortAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.sort");
|
||||
const sel = document.createElement("select");
|
||||
sel.className = "entity-select filter-bar-select";
|
||||
for (const s of SORTS) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.value;
|
||||
opt.textContent = t(s.key);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = ctx.get("sort") ?? "date_asc";
|
||||
sel.addEventListener("change", () => ctx.patch({ sort: sel.value as NonNullable<BarState["sort"]> }));
|
||||
wrap.appendChild(sel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Suppress unused warning for tDyn — it's available for future axes
|
||||
// (deadline_event_type) that need dynamic enum labels.
|
||||
void tDyn;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shared helpers — group + chip + row
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function group(labelKey: I18nKey): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "filter-group filter-bar-group";
|
||||
const label = document.createElement("span");
|
||||
label.className = "filter-bar-label";
|
||||
label.textContent = t(labelKey);
|
||||
wrap.appendChild(label);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function chipRow(): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "filter-bar-chip-row";
|
||||
return row;
|
||||
}
|
||||
|
||||
function chipBtn(text: string, active: boolean): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "agenda-chip filter-bar-chip" + (active ? " agenda-chip-active" : "");
|
||||
btn.textContent = text;
|
||||
return btn;
|
||||
}
|
||||
325
frontend/src/client/filter-bar/index.ts
Normal file
325
frontend/src/client/filter-bar/index.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
// FilterBar — the universal filter + view-mode primitive
|
||||
// (t-paliad-163). One client component every list-shaped paliad surface
|
||||
// mounts.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Caller hands in baseFilter + baseRender + axes + onResult.
|
||||
// 2. We parse URL params (within urlNamespace) and localStorage prefs,
|
||||
// overlay them on the base spec to compute the effective spec.
|
||||
// 3. We render the toolbar (one chip cluster / popover / select per
|
||||
// axis, plus trailing actions).
|
||||
// 4. We POST /api/views/{slug}/run with the effective spec as override
|
||||
// and hand the result + effective spec to onResult. The surface's
|
||||
// shape host renders.
|
||||
// 5. Every axis interaction patches BarState, re-encodes the URL,
|
||||
// re-runs the spec.
|
||||
//
|
||||
// The bar is a closed loop — surfaces don't see FilterSpec/RenderSpec
|
||||
// directly, just BarState diffs and the final ViewRunResult. That keeps
|
||||
// the substrate's validation invariants in one place (the bar).
|
||||
|
||||
import { onLangChange, t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult } from "../views/types";
|
||||
import {
|
||||
parseBar,
|
||||
encodeBar,
|
||||
} from "./url-codec";
|
||||
import { renderAxis, type AxisCtx } from "./axes";
|
||||
import { openSaveModal } from "./save-modal";
|
||||
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
|
||||
|
||||
export type { MountOpts, BarHandle, AxisKey } from "./types";
|
||||
|
||||
const PREFS_PREFIX = "paliad.bar.";
|
||||
|
||||
interface PrefsBlob {
|
||||
shape?: string;
|
||||
density?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
let state: BarState = {};
|
||||
const ns = opts.urlNamespace;
|
||||
|
||||
// Hydrate state: URL > localStorage prefs > base.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
state = parseBar(urlParams, ns);
|
||||
hydratePrefs(state, opts.surfaceKey);
|
||||
|
||||
// Toolbar shell.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "filter-bar";
|
||||
host.appendChild(toolbar);
|
||||
|
||||
// Trailing actions: Save as view + Reset (when not suppressed).
|
||||
const showSave = opts.showSaveAsView !== false;
|
||||
|
||||
// Run + render orchestration.
|
||||
let runVersion = 0;
|
||||
let lastEffective: EffectiveSpec | null = null;
|
||||
|
||||
const runAndRender = async () => {
|
||||
const effective = computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
lastEffective = effective;
|
||||
const myVersion = ++runVersion;
|
||||
try {
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(opts.systemViewSlug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filter: effective.filter }),
|
||||
});
|
||||
if (myVersion !== runVersion) return; // a newer click superseded us
|
||||
if (!r.ok) {
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
return;
|
||||
}
|
||||
const result = (await r.json()) as ViewRunResult;
|
||||
opts.onResult(result, effective);
|
||||
} catch (_e) {
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
}
|
||||
};
|
||||
|
||||
// Axis context — all axis renderers patch state through here.
|
||||
const ctx: AxisCtx = {
|
||||
get<K extends keyof BarState>(key: K) { return state[key]; },
|
||||
patch(delta) {
|
||||
state = { ...state, ...delta };
|
||||
// Coerce empties so URL stays clean.
|
||||
for (const k of Object.keys(delta) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (Array.isArray(v) && v.length === 0) delete state[k];
|
||||
if (v === undefined || v === null || v === false) delete state[k];
|
||||
}
|
||||
// personal_only false should also be deleted (handled above as
|
||||
// falsy, but explicit for clarity).
|
||||
if (state.personal_only === false) delete state.personal_only;
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
};
|
||||
|
||||
// First paint.
|
||||
const renderToolbar = () => {
|
||||
toolbar.innerHTML = "";
|
||||
for (const axis of opts.axes) {
|
||||
const el = renderAxis(axis as AxisKey, ctx);
|
||||
if (el) toolbar.appendChild(el);
|
||||
}
|
||||
if (showSave) {
|
||||
const trailing = document.createElement("div");
|
||||
trailing.className = "filter-bar-trailing";
|
||||
|
||||
const resetBtn = document.createElement("button");
|
||||
resetBtn.type = "button";
|
||||
resetBtn.className = "btn-secondary btn-small filter-bar-reset";
|
||||
resetBtn.textContent = t("views.bar.action.reset");
|
||||
resetBtn.disabled = !isDirty(state);
|
||||
resetBtn.addEventListener("click", () => handle.reset());
|
||||
trailing.appendChild(resetBtn);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "btn-primary btn-small filter-bar-save";
|
||||
saveBtn.textContent = t("views.bar.action.save_as_view");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!lastEffective) return;
|
||||
const result = await openSaveModal(lastEffective.filter, lastEffective.render);
|
||||
if (result) {
|
||||
window.location.href = `/views/${encodeURIComponent(result.view.slug)}`;
|
||||
}
|
||||
});
|
||||
trailing.appendChild(saveBtn);
|
||||
|
||||
toolbar.appendChild(trailing);
|
||||
}
|
||||
};
|
||||
|
||||
const syncURL = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
encodeBar(state, params, ns);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
history.replaceState(null, "", url);
|
||||
};
|
||||
|
||||
const syncPrefs = () => {
|
||||
const blob: PrefsBlob = {};
|
||||
if (state.shape) blob.shape = state.shape;
|
||||
if (state.density) blob.density = state.density;
|
||||
if (state.sort) blob.sort = state.sort;
|
||||
try {
|
||||
if (Object.keys(blob).length === 0) {
|
||||
localStorage.removeItem(PREFS_PREFIX + opts.surfaceKey);
|
||||
} else {
|
||||
localStorage.setItem(PREFS_PREFIX + opts.surfaceKey, JSON.stringify(blob));
|
||||
}
|
||||
} catch { /* private mode / quota — ignore */ }
|
||||
};
|
||||
|
||||
// Re-render labels on language change without losing state. The
|
||||
// existing onLangChange API is register-only (no off-handler). We
|
||||
// gate via a `destroyed` flag so a torn-down bar's callback no-ops.
|
||||
let destroyed = false;
|
||||
onLangChange(() => {
|
||||
if (destroyed) return;
|
||||
renderToolbar();
|
||||
});
|
||||
|
||||
const handle: BarHandle = {
|
||||
reset() {
|
||||
state = {};
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
async refresh() {
|
||||
await runAndRender();
|
||||
},
|
||||
getEffective() {
|
||||
if (lastEffective) return lastEffective;
|
||||
return computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
toolbar.remove();
|
||||
},
|
||||
};
|
||||
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
return handle;
|
||||
}
|
||||
|
||||
// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
|
||||
// in render axes the URL didn't already pin. URL wins over prefs.
|
||||
function hydratePrefs(state: BarState, surfaceKey: string): void {
|
||||
let blob: PrefsBlob;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
|
||||
if (!raw) return;
|
||||
blob = JSON.parse(raw) as PrefsBlob;
|
||||
} catch { return; }
|
||||
if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
|
||||
state.shape = blob.shape;
|
||||
}
|
||||
if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
|
||||
state.density = blob.density;
|
||||
}
|
||||
if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
|
||||
state.sort = blob.sort;
|
||||
}
|
||||
}
|
||||
|
||||
// computeEffective overlays the BarState onto the base FilterSpec +
|
||||
// RenderSpec to produce the spec that gets POSTed to the substrate.
|
||||
//
|
||||
// Server-side validator (FilterSpec.Validate) is the final gate; we
|
||||
// produce shapes the validator will accept, but defer to it for the
|
||||
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
|
||||
export function computeEffective(
|
||||
base: FilterSpec,
|
||||
baseRender: RenderSpec,
|
||||
state: BarState,
|
||||
): EffectiveSpec {
|
||||
// Deep-clone to avoid mutating the caller's base. JSON round-trip is
|
||||
// fine here — every field on FilterSpec is a primitive / array /
|
||||
// object literal (no class instances, no Date, no functions).
|
||||
const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
|
||||
const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;
|
||||
|
||||
if (state.time) {
|
||||
filter.time = {
|
||||
...filter.time,
|
||||
horizon: state.time.horizon,
|
||||
from: state.time.horizon === "custom" ? state.time.from : undefined,
|
||||
to: state.time.horizon === "custom" ? state.time.to : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
personal_only: true,
|
||||
// When personal_only takes over, leave projects on the base
|
||||
// mode (typically all_visible). Validator rejects ScopeExplicit
|
||||
// + personal_only so we don't overwrite the mode here.
|
||||
};
|
||||
} else if (state.project.id) {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
projects: { mode: "explicit", ids: [state.project.id] },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (state.personal_only) {
|
||||
filter.scope = { ...filter.scope, personal_only: true };
|
||||
}
|
||||
|
||||
// Per-source predicates. Build the predicates map idempotently;
|
||||
// never inject a predicate for a source the spec doesn't list.
|
||||
const sources = new Set(filter.sources);
|
||||
filter.predicates = filter.predicates ?? {};
|
||||
|
||||
if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
|
||||
const cur = filter.predicates.deadline ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.deadline_status) next.status = state.deadline_status;
|
||||
if (state.deadline_event_type) {
|
||||
next.event_types = state.deadline_event_type.ids;
|
||||
next.include_untyped = state.deadline_event_type.include_untyped;
|
||||
}
|
||||
filter.predicates.deadline = next;
|
||||
}
|
||||
if (sources.has("appointment") && state.appointment_type) {
|
||||
const cur = filter.predicates.appointment ?? {};
|
||||
filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
|
||||
}
|
||||
if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
|
||||
const cur = filter.predicates.approval_request ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
|
||||
if (state.approval_status) next.status = state.approval_status;
|
||||
if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
|
||||
filter.predicates.approval_request = next;
|
||||
}
|
||||
if (sources.has("project_event") && state.project_event_kind) {
|
||||
const cur = filter.predicates.project_event ?? {};
|
||||
filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
|
||||
}
|
||||
|
||||
// Render overlays.
|
||||
if (state.shape) render.shape = state.shape;
|
||||
if (state.sort) {
|
||||
if (render.shape === "list" || (state.shape === "list" && !render.list)) {
|
||||
render.list = { ...(render.list ?? {}), sort: state.sort };
|
||||
}
|
||||
if (render.shape === "cards" || state.shape === "cards") {
|
||||
render.cards = { ...(render.cards ?? {}), sort: state.sort };
|
||||
}
|
||||
}
|
||||
if (state.density && (render.shape === "list" || state.shape === "list")) {
|
||||
render.list = { ...(render.list ?? {}), density: state.density };
|
||||
}
|
||||
|
||||
return { filter, render };
|
||||
}
|
||||
|
||||
// isDirty — used to enable the Reset button only when there's something
|
||||
// to reset to.
|
||||
function isDirty(state: BarState): boolean {
|
||||
for (const k of Object.keys(state) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Save-as-view modal for the FilterBar. Mirrors the create form on
|
||||
// /views/new (frontend/src/client/views-editor.ts:168) but as a modal
|
||||
// so the user can save the bar's current effective spec without
|
||||
// leaving the page they're filtering on.
|
||||
//
|
||||
// On success, the new view appears in the "Meine Sichten" sidebar
|
||||
// group on next render (the sidebar polls /api/user-views on init).
|
||||
|
||||
import { t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, UserView } from "../views/types";
|
||||
|
||||
export interface SaveModalResult {
|
||||
view: UserView;
|
||||
}
|
||||
|
||||
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
export function openSaveModal(filter: FilterSpec, render: RenderSpec): Promise<SaveModalResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "filter-bar-save-modal";
|
||||
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog" class="filter-bar-save-form">
|
||||
<h2>${t("views.bar.save.heading")}</h2>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.name")}</span>
|
||||
<input type="text" name="name" required maxlength="100" autocomplete="off" />
|
||||
</label>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.slug")}</span>
|
||||
<input type="text" name="slug" required maxlength="63" autocomplete="off" pattern="[a-z0-9][a-z0-9-]*" />
|
||||
<small>${t("views.bar.save.field.slug_hint")}</small>
|
||||
</label>
|
||||
<label class="filter-bar-save-checkbox">
|
||||
<input type="checkbox" name="show_count" />
|
||||
<span>${t("views.bar.save.field.show_count")}</span>
|
||||
</label>
|
||||
<p class="filter-bar-save-error" hidden></p>
|
||||
<div class="filter-bar-save-actions">
|
||||
<button type="button" class="btn-secondary" data-action="cancel">${t("views.bar.save.cancel")}</button>
|
||||
<button type="submit" class="btn-primary">${t("views.bar.save.confirm")}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const form = dialog.querySelector<HTMLFormElement>(".filter-bar-save-form")!;
|
||||
const errorEl = dialog.querySelector<HTMLParagraphElement>(".filter-bar-save-error")!;
|
||||
const nameInput = form.elements.namedItem("name") as HTMLInputElement;
|
||||
const slugInput = form.elements.namedItem("slug") as HTMLInputElement;
|
||||
const showCount = form.elements.namedItem("show_count") as HTMLInputElement;
|
||||
const cancelBtn = dialog.querySelector<HTMLButtonElement>('[data-action="cancel"]')!;
|
||||
|
||||
// Auto-derive slug from name as the user types — but only until
|
||||
// they touch the slug field manually.
|
||||
let slugDirty = false;
|
||||
nameInput.addEventListener("input", () => {
|
||||
if (!slugDirty) slugInput.value = derivedSlug(nameInput.value);
|
||||
});
|
||||
slugInput.addEventListener("input", () => { slugDirty = true; });
|
||||
|
||||
const cleanup = () => {
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
};
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.hidden = true;
|
||||
errorEl.textContent = "";
|
||||
|
||||
const name = nameInput.value.trim();
|
||||
const slug = slugInput.value.trim();
|
||||
if (!name) {
|
||||
showError(errorEl, t("views.bar.save.error.name_required"));
|
||||
return;
|
||||
}
|
||||
if (!SLUG_REGEX.test(slug)) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_format"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
slug,
|
||||
filter_spec: filter,
|
||||
render_spec: render,
|
||||
show_count: showCount.checked,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/api/user-views", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (r.status === 409) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_taken"));
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
showError(errorEl, body.error || `${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
const view = (await r.json()) as UserView;
|
||||
cleanup();
|
||||
resolve({ view });
|
||||
} catch (_e) {
|
||||
showError(errorEl, t("views.bar.save.error.network"));
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
dialog.showModal();
|
||||
nameInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function showError(el: HTMLElement, msg: string): void {
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function derivedSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, "ae")
|
||||
.replace(/[öÖ]/g, "oe")
|
||||
.replace(/[üÜ]/g, "ue")
|
||||
.replace(/[ß]/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63);
|
||||
}
|
||||
132
frontend/src/client/filter-bar/types.ts
Normal file
132
frontend/src/client/filter-bar/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// FilterBar types — t-paliad-163. Mirrors the Go FilterSpec/RenderSpec
|
||||
// shapes from internal/services/{filter_spec,render_spec}.go via
|
||||
// client/views/types.ts. The FilterBar is the universal frontend
|
||||
// primitive that consumes a base FilterSpec + RenderSpec, declares
|
||||
// which axes the surface supports, and emits diffs back through
|
||||
// onResult after running the spec via /api/views/run.
|
||||
|
||||
import type { FilterSpec, RenderSpec, RenderShape, ViewRunResult, ListRowAction } from "../views/types";
|
||||
|
||||
// AxisKey — every filter dimension the bar can render. Declared per
|
||||
// surface in mountFilterBar's `axes` array. See design §3.1 for the
|
||||
// universal-vs-per-surface split.
|
||||
export type AxisKey =
|
||||
| "time"
|
||||
| "project"
|
||||
| "personal_only"
|
||||
| "deadline_status"
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "shape"
|
||||
| "sort"
|
||||
| "density";
|
||||
|
||||
// Effective spec — the result of overlaying URL + localStorage prefs
|
||||
// on top of the base spec. Handed back to onResult so the surface can
|
||||
// dispatch into the matching shape renderer with the right config.
|
||||
export interface EffectiveSpec {
|
||||
filter: FilterSpec;
|
||||
render: RenderSpec;
|
||||
}
|
||||
|
||||
// Per-axis state — what the URL codec round-trips. Each axis's value
|
||||
// type is bounded to the FilterSpec/RenderSpec subset it touches.
|
||||
export interface BarState {
|
||||
// Universal
|
||||
time?: TimeOverlay;
|
||||
project?: ProjectOverlay;
|
||||
personal_only?: boolean;
|
||||
|
||||
// Per-source
|
||||
deadline_status?: string[];
|
||||
deadline_event_type?: { ids: string[]; include_untyped: boolean };
|
||||
appointment_type?: string[];
|
||||
approval_viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
|
||||
approval_status?: string[];
|
||||
approval_entity_type?: string[];
|
||||
project_event_kind?: string[];
|
||||
|
||||
// Render
|
||||
shape?: RenderShape;
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
}
|
||||
|
||||
export interface TimeOverlay {
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface ProjectOverlay {
|
||||
// The bar's project chip is single-select today; Phase C upgrades
|
||||
// to multi-select. "personal" is a sentinel — the legacy /events
|
||||
// contract reserved this name, we keep it so old bookmarks still
|
||||
// resolve to the right state.
|
||||
mode: "single" | "personal";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// MountOpts — the public API.
|
||||
export interface MountOpts {
|
||||
// Base spec. Usually a SystemView's FilterSpec+RenderSpec, fetched
|
||||
// from /api/views/system on the surface and passed in here. For
|
||||
// /views/{slug}, the saved user-view's spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface exposes. Order is preserved in the rendered
|
||||
// chrome — surfaces use this to control left-to-right grouping.
|
||||
axes: AxisKey[];
|
||||
|
||||
// URL parameter namespace. When set, every URL key is prefixed
|
||||
// (`?<ns>_time=`, `?<ns>_project=`, …). Used when two bars share a
|
||||
// page (dashboard inline lists). Defaults to no prefix.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Surface key for localStorage prefs (density, default shape).
|
||||
// Required so two surfaces don't share preferences.
|
||||
surfaceKey: string;
|
||||
|
||||
// Whether to render "Speichern als Sicht" + "Zurücksetzen"
|
||||
// trailing actions. Defaults to true. Set false on the dashboard
|
||||
// inline bars (per design Q6).
|
||||
showSaveAsView?: boolean;
|
||||
|
||||
// Slug of the surface's underlying system view (or saved user view).
|
||||
// POSTed to /api/views/{slug}/run with the override body. Required —
|
||||
// the bar runs through that endpoint, never the ad-hoc /api/views/run,
|
||||
// so the substrate's reserved-slug path stays the canonical entry.
|
||||
systemViewSlug: string;
|
||||
|
||||
// When true, the bar exposes an "Aktualisieren" affordance that
|
||||
// PATCHes /api/user-views/{userViewId} with the effective spec.
|
||||
// Set on /views/{slug} where the user is viewing a saved view.
|
||||
userViewId?: string;
|
||||
|
||||
// Called every time the spec changes (mount, URL change, axis
|
||||
// interaction). The surface dispatches to the matching shape
|
||||
// renderer with the rows from /api/views/{slug}/run.
|
||||
onResult(result: ViewRunResult, effective: EffectiveSpec): void;
|
||||
|
||||
// Optional — surface-specific row-action override. Phase 1: /inbox
|
||||
// pins this to "approve"; /events Phase 3 pins to "complete_toggle".
|
||||
// Future: sourced from the spec's render.list.row_action when set.
|
||||
rowAction?: ListRowAction;
|
||||
}
|
||||
|
||||
// Bar handle — what mountFilterBar returns. Pages can call .reset()
|
||||
// from page-level controls (e.g. an empty-state "Filter zurücksetzen"
|
||||
// button), or .destroy() if the page tears down.
|
||||
export interface BarHandle {
|
||||
reset(): void;
|
||||
refresh(): Promise<void>;
|
||||
destroy(): void;
|
||||
// Read-only effective spec at this moment (post URL + localStorage
|
||||
// overlay). Pages use this to construct deep-link URLs etc.
|
||||
getEffective(): EffectiveSpec;
|
||||
}
|
||||
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Unit tests for the FilterBar URL codec. Round-trip discipline:
|
||||
// every BarState shape parseBar produces must encode back to the same
|
||||
// URL params, and vice versa. Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { parseBar, encodeBar } from "./url-codec";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
function roundTrip(state: BarState, ns?: string): BarState {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params, ns);
|
||||
return parseBar(params, ns);
|
||||
}
|
||||
|
||||
describe("filter-bar/url-codec", () => {
|
||||
test("empty state round-trips to empty", () => {
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("time horizon round-trips", () => {
|
||||
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
|
||||
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom time horizon round-trips with from + to", () => {
|
||||
const state: BarState = { time: { horizon: "custom", from: "2026-01-01", to: "2026-12-31" } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("project sentinel + uuid round-trip", () => {
|
||||
expect(roundTrip({ project: { mode: "personal" } })).toEqual({ project: { mode: "personal" } });
|
||||
expect(roundTrip({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } }))
|
||||
.toEqual({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } });
|
||||
});
|
||||
|
||||
test("personal_only flag round-trips", () => {
|
||||
expect(roundTrip({ personal_only: true })).toEqual({ personal_only: true });
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("deadline_event_type honours legacy 'none' sentinel", () => {
|
||||
const state: BarState = { deadline_event_type: { ids: ["a", "b"], include_untyped: true } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
const state2: BarState = { deadline_event_type: { ids: [], include_untyped: true } };
|
||||
expect(roundTrip(state2)).toEqual(state2);
|
||||
const state3: BarState = { deadline_event_type: { ids: ["a"], include_untyped: false } };
|
||||
expect(roundTrip(state3)).toEqual(state3);
|
||||
});
|
||||
|
||||
test("approval_request triple round-trips together", () => {
|
||||
const state: BarState = {
|
||||
approval_viewer_role: "approver_eligible",
|
||||
approval_status: ["pending", "approved"],
|
||||
approval_entity_type: ["deadline"],
|
||||
};
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("namespace prefix isolates two bars on the same page", () => {
|
||||
const a: BarState = { time: { horizon: "next_7d" } };
|
||||
const b: BarState = { time: { horizon: "next_30d" } };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(a, params, "agenda");
|
||||
encodeBar(b, params, "activity");
|
||||
expect(parseBar(params, "agenda")).toEqual(a);
|
||||
expect(parseBar(params, "activity")).toEqual(b);
|
||||
// Without namespace neither bar's keys are visible.
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
|
||||
test("render axes round-trip", () => {
|
||||
const state: BarState = { shape: "cards", sort: "date_desc", density: "compact" };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("encode is idempotent — re-encoding same state replaces, doesn't accumulate", () => {
|
||||
const state: BarState = { time: { horizon: "next_7d" }, deadline_status: ["pending"] };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params);
|
||||
encodeBar(state, params);
|
||||
expect(params.get("d_status")).toBe("pending");
|
||||
// Only one entry per key.
|
||||
expect(params.getAll("d_status")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("encode replaces stale keys when state shrinks", () => {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar({ deadline_status: ["pending"], approval_viewer_role: "self_requested" }, params);
|
||||
encodeBar({ deadline_status: ["completed"] }, params);
|
||||
expect(params.get("d_status")).toBe("completed");
|
||||
expect(params.has("a_role")).toBe(false);
|
||||
});
|
||||
|
||||
test("parse drops unknown enum values silently (forward-compat)", () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("a_role", "future_role_we_dont_know_yet");
|
||||
params.set("shape", "kanban");
|
||||
params.set("density", "huge");
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
});
|
||||
188
frontend/src/client/filter-bar/url-codec.ts
Normal file
188
frontend/src/client/filter-bar/url-codec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// FilterBar URL codec — t-paliad-163. Encodes BarState ↔ URL
|
||||
// parameters with optional namespace prefix (?<ns>_<key>=).
|
||||
//
|
||||
// The bar treats the URL as canonical for everything that affects
|
||||
// which rows you see. Round-trip discipline: anything written by
|
||||
// encodeBar must parse back identically via parseBar so deep-links
|
||||
// and refresh both yield the same effective spec.
|
||||
//
|
||||
// Empty / default values are NOT written — the URL stays clean for
|
||||
// users who don't tweak. The page's base spec is the implicit baseline.
|
||||
|
||||
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
|
||||
|
||||
const PERSONAL_PROJECT_SENTINEL = "personal";
|
||||
|
||||
// parseBar reads URL params into a BarState. Unknown values are
|
||||
// dropped silently (forward-compat with future axes).
|
||||
export function parseBar(params: URLSearchParams, ns?: string): BarState {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
const out: BarState = {};
|
||||
|
||||
// time
|
||||
const time = params.get(k("time"));
|
||||
if (time) {
|
||||
const horizon = parseHorizon(time);
|
||||
if (horizon) {
|
||||
const overlay: TimeOverlay = { horizon };
|
||||
if (horizon === "custom") {
|
||||
const from = params.get(k("from"));
|
||||
const to = params.get(k("to"));
|
||||
if (from) overlay.from = from;
|
||||
if (to) overlay.to = to;
|
||||
}
|
||||
out.time = overlay;
|
||||
}
|
||||
}
|
||||
|
||||
// project
|
||||
const project = params.get(k("project"));
|
||||
if (project) {
|
||||
if (project === PERSONAL_PROJECT_SENTINEL) {
|
||||
out.project = { mode: "personal" };
|
||||
} else {
|
||||
out.project = { mode: "single", id: project };
|
||||
}
|
||||
}
|
||||
|
||||
// personal_only
|
||||
if (params.get(k("personal")) === "1") {
|
||||
out.personal_only = true;
|
||||
}
|
||||
|
||||
// deadline.status
|
||||
const dStatus = params.get(k("d_status"));
|
||||
if (dStatus) out.deadline_status = parseCSV(dStatus);
|
||||
|
||||
// deadline.event_types — preserves the legacy /events contract
|
||||
// where "none" inside the CSV means include_untyped=true.
|
||||
const dEvent = params.get(k("d_event_type"));
|
||||
if (dEvent) {
|
||||
const tokens = parseCSV(dEvent);
|
||||
const ids: string[] = [];
|
||||
let untyped = false;
|
||||
for (const tok of tokens) {
|
||||
if (tok === "none") untyped = true;
|
||||
else ids.push(tok);
|
||||
}
|
||||
out.deadline_event_type = { ids, include_untyped: untyped };
|
||||
}
|
||||
|
||||
// appointment.types
|
||||
const appType = params.get(k("app_type"));
|
||||
if (appType) out.appointment_type = parseCSV(appType);
|
||||
|
||||
// approval_request.viewer_role
|
||||
const aRole = params.get(k("a_role"));
|
||||
if (aRole === "approver_eligible" || aRole === "self_requested" || aRole === "any_visible") {
|
||||
out.approval_viewer_role = aRole;
|
||||
}
|
||||
|
||||
// approval_request.status
|
||||
const aStatus = params.get(k("a_status"));
|
||||
if (aStatus) out.approval_status = parseCSV(aStatus);
|
||||
|
||||
// approval_request.entity_types
|
||||
const aEntity = params.get(k("a_entity_type"));
|
||||
if (aEntity) out.approval_entity_type = parseCSV(aEntity);
|
||||
|
||||
// project_event.event_types
|
||||
const peKind = params.get(k("pe_kind"));
|
||||
if (peKind) out.project_event_kind = parseCSV(peKind);
|
||||
|
||||
// render.shape
|
||||
const shape = params.get(k("shape"));
|
||||
if (shape === "list" || shape === "cards" || shape === "calendar") out.shape = shape;
|
||||
|
||||
// render.list.sort / render.cards.sort — the bar treats sort as one axis
|
||||
const sort = params.get(k("sort"));
|
||||
if (sort === "date_asc" || sort === "date_desc") out.sort = sort;
|
||||
|
||||
// render.list.density
|
||||
const density = params.get(k("density"));
|
||||
if (density === "comfortable" || density === "compact") out.density = density;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// encodeBar writes BarState back into URL params, mutating the
|
||||
// passed-in URLSearchParams. Empty / undefined values are omitted.
|
||||
// The caller controls how the result is applied (history.replaceState
|
||||
// with the page pathname unchanged).
|
||||
export function encodeBar(state: BarState, params: URLSearchParams, ns?: string): void {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
|
||||
// Clear every key the bar owns first, then re-write the non-empty ones.
|
||||
for (const key of [
|
||||
"time", "from", "to", "project", "personal",
|
||||
"d_status", "d_event_type",
|
||||
"app_type",
|
||||
"a_role", "a_status", "a_entity_type",
|
||||
"pe_kind",
|
||||
"shape", "sort", "density",
|
||||
]) {
|
||||
params.delete(k(key));
|
||||
}
|
||||
|
||||
if (state.time) {
|
||||
params.set(k("time"), state.time.horizon);
|
||||
if (state.time.horizon === "custom") {
|
||||
if (state.time.from) params.set(k("from"), state.time.from);
|
||||
if (state.time.to) params.set(k("to"), state.time.to);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
params.set(k("project"), PERSONAL_PROJECT_SENTINEL);
|
||||
} else if (state.project.id) {
|
||||
params.set(k("project"), state.project.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.personal_only) params.set(k("personal"), "1");
|
||||
|
||||
if (state.deadline_status?.length) params.set(k("d_status"), state.deadline_status.join(","));
|
||||
|
||||
if (state.deadline_event_type) {
|
||||
const parts = [...state.deadline_event_type.ids];
|
||||
if (state.deadline_event_type.include_untyped) parts.push("none");
|
||||
if (parts.length) params.set(k("d_event_type"), parts.join(","));
|
||||
}
|
||||
|
||||
if (state.appointment_type?.length) params.set(k("app_type"), state.appointment_type.join(","));
|
||||
if (state.approval_viewer_role) params.set(k("a_role"), state.approval_viewer_role);
|
||||
if (state.approval_status?.length) params.set(k("a_status"), state.approval_status.join(","));
|
||||
if (state.approval_entity_type?.length) params.set(k("a_entity_type"), state.approval_entity_type.join(","));
|
||||
if (state.project_event_kind?.length) params.set(k("pe_kind"), state.project_event_kind.join(","));
|
||||
|
||||
if (state.shape) params.set(k("shape"), state.shape);
|
||||
if (state.sort) params.set(k("sort"), state.sort);
|
||||
if (state.density) params.set(k("density"), state.density);
|
||||
}
|
||||
|
||||
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
|
||||
switch (s) {
|
||||
case "next_7d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
return s;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCSV(s: string): string[] {
|
||||
return s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export { PERSONAL_PROJECT_SENTINEL };
|
||||
|
||||
// Re-exported so consumers don't need to import ProjectOverlay just
|
||||
// to construct one in tests.
|
||||
export type { ProjectOverlay };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
211
frontend/src/client/inbox.ts
Normal file
211
frontend/src/client/inbox.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// The bar owns every axis the old tab UI exposed plus more:
|
||||
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
|
||||
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
|
||||
// - approval_status: chip cluster (default: pending)
|
||||
// - approval_entity_type: chip pair (Frist / Termin)
|
||||
// - time: chip cluster (Any default)
|
||||
// - density: comfortable / compact
|
||||
// - sort: date asc / desc
|
||||
//
|
||||
// Row rendering: shape-list.ts with row_action="approve" stamps the
|
||||
// inbox markup (entity title, diff, approve/reject/revoke buttons).
|
||||
// We wire action click handlers in onResult and refresh through the
|
||||
// bar handle.
|
||||
|
||||
const INBOX_AXES: AxisKey[] = [
|
||||
"time",
|
||||
"approval_viewer_role",
|
||||
"approval_status",
|
||||
"approval_entity_type",
|
||||
"density",
|
||||
"sort",
|
||||
];
|
||||
|
||||
let bar: BarHandle | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
applyLegacyTabRedirect();
|
||||
void hydrate();
|
||||
});
|
||||
|
||||
// ?tab=pending-mine | mine -> ?a_role=approver_eligible | self_requested.
|
||||
// Done client-side because /inbox serves a static dist file (no Go
|
||||
// router involvement). Bookmarks from the sidebar bell + outbound
|
||||
// emails keep landing on the right sub-view through the bar.
|
||||
function applyLegacyTabRedirect(): void {
|
||||
const url = new URL(window.location.href);
|
||||
const tab = url.searchParams.get("tab");
|
||||
if (!tab) return;
|
||||
url.searchParams.delete("tab");
|
||||
if (tab === "mine") {
|
||||
url.searchParams.set("a_role", "self_requested");
|
||||
} else if (tab === "pending-mine") {
|
||||
url.searchParams.set("a_role", "approver_eligible");
|
||||
}
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
async function hydrate(): Promise<void> {
|
||||
const host = document.getElementById("inbox-filter-bar");
|
||||
const loading = document.getElementById("inbox-loading");
|
||||
const results = document.getElementById("inbox-results");
|
||||
const empty = document.getElementById("inbox-empty");
|
||||
if (!host || !loading || !results || !empty) return;
|
||||
|
||||
const sys = await fetchInboxSystemView();
|
||||
if (!sys) {
|
||||
loading.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.error.internal");
|
||||
return;
|
||||
}
|
||||
|
||||
bar = mountFilterBar(host, {
|
||||
baseFilter: sys.Filter,
|
||||
baseRender: sys.Render,
|
||||
axes: INBOX_AXES,
|
||||
surfaceKey: "inbox",
|
||||
systemViewSlug: sys.Slug,
|
||||
onResult: (result, effective) => paint(result, effective.render, results, empty, loading),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchInboxSystemView(): Promise<SystemView | null> {
|
||||
try {
|
||||
const r = await fetch("/api/views/system", { credentials: "include" });
|
||||
if (!r.ok) return null;
|
||||
const list = (await r.json()) as SystemView[];
|
||||
return list.find((v) => v.Slug === "inbox") ?? null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function paint(
|
||||
result: ViewRunResult,
|
||||
render: RenderSpec,
|
||||
results: HTMLElement,
|
||||
empty: HTMLElement,
|
||||
loading: HTMLElement,
|
||||
): void {
|
||||
loading.style.display = "none";
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
results.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.empty.pending_mine");
|
||||
void maybeShowAdminNudge();
|
||||
return;
|
||||
}
|
||||
hideAdminNudge();
|
||||
empty.style.display = "none";
|
||||
|
||||
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
||||
// RenderSpec sets row_action="approve" so we get the inbox markup.
|
||||
renderListShape(results, result.rows, render);
|
||||
|
||||
// Wire action handlers on the freshly stamped DOM. The action
|
||||
// POSTs land on the same endpoints the legacy /inbox used; on
|
||||
// success we trigger a bar refresh so the new state propagates.
|
||||
wireApprovalActions(results);
|
||||
}
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${id}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-154 — show the admin-only "configure policies" nudge when:
|
||||
// - current user is global_admin
|
||||
// - inbox empty
|
||||
// - no approval_policies row exists firm-wide
|
||||
async function maybeShowAdminNudge(): Promise<void> {
|
||||
const nudge = document.getElementById("inbox-admin-nudge");
|
||||
if (!nudge) return;
|
||||
try {
|
||||
const meR = await fetch("/api/me", { credentials: "include" });
|
||||
if (!meR.ok) return;
|
||||
const me = (await meR.json()) as { global_role?: string };
|
||||
if (me.global_role !== "global_admin") return;
|
||||
|
||||
const seedR = await fetch("/api/admin/approval-policies/seeded", { credentials: "include" });
|
||||
if (!seedR.ok) return;
|
||||
const data = (await seedR.json()) as { any: boolean };
|
||||
if (data.any) return;
|
||||
|
||||
nudge.style.display = "";
|
||||
} catch (_e) { /* keep hidden */ }
|
||||
}
|
||||
|
||||
function hideAdminNudge(): void {
|
||||
const nudge = document.getElementById("inbox-admin-nudge");
|
||||
if (nudge) nudge.style.display = "none";
|
||||
}
|
||||
|
||||
async function refreshInboxBadge(): Promise<void> {
|
||||
const badge = document.getElementById("sidebar-inbox-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
const r = await fetch("/api/inbox/count", { credentials: "include" });
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { count: number };
|
||||
if (data.count > 0) {
|
||||
badge.textContent = String(data.count);
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
}
|
||||
@@ -122,6 +122,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
const displayName = (data.get("display_name") as string || "").trim();
|
||||
const office = (data.get("office") as string || "").trim();
|
||||
const jobTitle = (data.get("job_title") as string || "").trim();
|
||||
const profession = (data.get("profession") as string || "").trim();
|
||||
const partnerUnitID = (data.get("partner_unit_id") as string || "").trim();
|
||||
|
||||
if (!displayName) {
|
||||
@@ -141,6 +142,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
display_name: displayName,
|
||||
office,
|
||||
job_title: jobTitle,
|
||||
profession,
|
||||
};
|
||||
if (partnerUnitID) payload.partner_unit_id = partnerUnitID;
|
||||
|
||||
|
||||
196
frontend/src/client/paliadin-context.ts
Normal file
196
frontend/src/client/paliadin-context.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// paliadin-context.ts — structured page-context payload builder for the
|
||||
// Paliadin inline widget (t-paliad-161).
|
||||
//
|
||||
// The standalone /paliadin page submits turns with only `page_origin`
|
||||
// (single string, the URL pathname). The inline widget submits a richer
|
||||
// payload: route_name + primary_entity_type + primary_entity_id + the
|
||||
// user's text selection + UI hints. The Go backend persists this jsonb
|
||||
// in paliad.paliadin_turns.context (migration 070) AND prepends a
|
||||
// flattened `[ctx …]` block to the tmux envelope so SKILL.md can branch
|
||||
// on it before answering.
|
||||
//
|
||||
// Design: docs/design-paliadin-inline-2026-05-08.md §4.
|
||||
|
||||
export interface PaliadinContext {
|
||||
route_name: string;
|
||||
page_origin: string;
|
||||
primary_entity_type?: "project" | "deadline" | "appointment";
|
||||
primary_entity_id?: string;
|
||||
user_selection_text?: string;
|
||||
view_mode?: "list" | "cards" | "calendar" | "tree";
|
||||
filter_summary?: string;
|
||||
}
|
||||
|
||||
const SELECTION_MAX = 1000;
|
||||
|
||||
// UUID match — relaxed: any 8-4-4-4-12 hex pattern. Catches /projects/<id>
|
||||
// and /deadlines/<id> regardless of trailing path segments.
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Compute the Paliadin context for the current page. Reads
|
||||
* window.location + window.getSelection() at call time, so callers
|
||||
* should invoke this immediately before sending a turn — not at widget
|
||||
* boot — to capture the user's selection in the moment they typed.
|
||||
*
|
||||
* Returns null when the visibility predicate fails (e.g. on /paliadin,
|
||||
* /login, /onboarding) — callers SHOULD short-circuit on null instead
|
||||
* of sending an empty payload.
|
||||
*/
|
||||
export function computePaliadinContext(): PaliadinContext | null {
|
||||
const pathname = window.location.pathname || "";
|
||||
if (!shouldSendContext(pathname)) {
|
||||
return null;
|
||||
}
|
||||
const search = window.location.search || "";
|
||||
const ctx: PaliadinContext = {
|
||||
route_name: routeNameFor(pathname),
|
||||
page_origin: pathname + search,
|
||||
};
|
||||
const entity = extractPrimaryEntity(pathname);
|
||||
if (entity) {
|
||||
ctx.primary_entity_type = entity.type;
|
||||
ctx.primary_entity_id = entity.id;
|
||||
}
|
||||
const selection = readSelection();
|
||||
if (selection) {
|
||||
ctx.user_selection_text = selection;
|
||||
}
|
||||
const view = readViewMode();
|
||||
if (view) {
|
||||
ctx.view_mode = view;
|
||||
}
|
||||
const filter = readFilterSummary();
|
||||
if (filter) {
|
||||
ctx.filter_summary = filter;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget hides itself on routes where Paliadin is either redundant
|
||||
* (the standalone /paliadin) or unavailable (auth flows). Mirrored here
|
||||
* for the context-payload predicate so a stray send from one of those
|
||||
* pages doesn't surface an empty `[ctx]` block.
|
||||
*/
|
||||
export function shouldSendContext(pathname: string): boolean {
|
||||
if (pathname === "/paliadin" || pathname.startsWith("/paliadin/")) return false;
|
||||
if (pathname === "/login" || pathname.startsWith("/login/")) return false;
|
||||
if (pathname === "/onboarding") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a URL pathname to a stable route key. Stable across query-string
|
||||
* + ID variations so the SKILL.md / starter registry can branch on it
|
||||
* without fragile URL parsing.
|
||||
*/
|
||||
export function routeNameFor(pathname: string): string {
|
||||
// Order matters — most-specific first.
|
||||
if (/^\/projects\/[^/]+$/.test(pathname)) return "projects.detail";
|
||||
if (pathname === "/projects" || pathname === "/projects/") return "projects.list";
|
||||
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
|
||||
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
|
||||
if (pathname === "/deadlines/new") return "deadlines.new";
|
||||
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
|
||||
if (pathname === "/deadlines") return "deadlines.list";
|
||||
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
|
||||
if (pathname === "/appointments/new") return "appointments.new";
|
||||
if (pathname === "/appointments/calendar") return "appointments.calendar";
|
||||
if (pathname === "/appointments") return "appointments.list";
|
||||
if (pathname === "/agenda") return "agenda";
|
||||
if (pathname === "/inbox") return "inbox";
|
||||
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
|
||||
if (pathname === "/team") return "team";
|
||||
if (pathname === "/courts") return "courts";
|
||||
if (pathname === "/glossary") return "glossary";
|
||||
if (pathname === "/links") return "links";
|
||||
if (pathname === "/downloads") return "downloads";
|
||||
if (pathname === "/checklists") return "checklists";
|
||||
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
|
||||
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
|
||||
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
|
||||
if (pathname === "/events") return "events";
|
||||
if (pathname.startsWith("/views/")) return "views.detail";
|
||||
if (pathname === "/views") return "views.list";
|
||||
if (pathname.startsWith("/admin/")) return "admin." + pathname.slice("/admin/".length).split("/")[0];
|
||||
if (pathname === "/admin") return "admin";
|
||||
if (pathname === "/settings") return "settings";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the primary entity (type + uuid) out of the URL when the route
|
||||
* encodes one. Returns null on routes that have no primary entity
|
||||
* (dashboard, agenda, lists, tools).
|
||||
*/
|
||||
export function extractPrimaryEntity(
|
||||
pathname: string,
|
||||
): { type: "project" | "deadline" | "appointment"; id: string } | null {
|
||||
const projectMatch = pathname.match(/^\/projects\/([^/]+)(?:\/|$)/);
|
||||
if (projectMatch && UUID_RE.test(projectMatch[1])) {
|
||||
return { type: "project", id: projectMatch[1] };
|
||||
}
|
||||
const deadlineMatch = pathname.match(/^\/deadlines\/([^/]+)$/);
|
||||
if (deadlineMatch && UUID_RE.test(deadlineMatch[1])) {
|
||||
return { type: "deadline", id: deadlineMatch[1] };
|
||||
}
|
||||
const apptMatch = pathname.match(/^\/appointments\/([^/]+)$/);
|
||||
if (apptMatch && UUID_RE.test(apptMatch[1])) {
|
||||
return { type: "appointment", id: apptMatch[1] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the user's current text selection, capped at SELECTION_MAX.
|
||||
* Returns empty string when there's no selection or when the selection
|
||||
* is collapsed (caret with no range).
|
||||
*
|
||||
* Privacy floor (§4.3): respects the widget's "send selection" toggle,
|
||||
* stored in localStorage under `paliadin:send-selection`. Default on
|
||||
* (m's Q5 lock-in); flip to off → returns empty string regardless of
|
||||
* what's selected.
|
||||
*/
|
||||
export function readSelection(): string {
|
||||
if (localStorage.getItem("paliadin:send-selection") === "off") {
|
||||
return "";
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed) return "";
|
||||
const text = sel.toString().trim();
|
||||
if (!text) return "";
|
||||
if (text.length > SELECTION_MAX) {
|
||||
return text.slice(0, SELECTION_MAX);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the page for an active "view mode" hint — set by /events,
|
||||
* /projects (tree vs list), /deadlines (calendar vs list). The frontend
|
||||
* stores these as `data-view-mode` attributes on a known root element
|
||||
* or in localStorage; this helper centralises the lookup so future
|
||||
* pages adding a new view mode don't have to teach the widget about
|
||||
* themselves.
|
||||
*/
|
||||
export function readViewMode(): "list" | "cards" | "calendar" | "tree" | "" {
|
||||
const root = document.querySelector<HTMLElement>("[data-view-mode]");
|
||||
if (!root) return "";
|
||||
const v = root.dataset.viewMode || "";
|
||||
if (v === "list" || v === "cards" || v === "calendar" || v === "tree") return v;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a short human-readable summary of active list filters from a
|
||||
* known DOM hook. Pages that want to participate set
|
||||
* `data-filter-summary="status=overdue · project=Acme"` on a root
|
||||
* element. Empty = no summary.
|
||||
*/
|
||||
export function readFilterSummary(): string {
|
||||
const root = document.querySelector<HTMLElement>("[data-filter-summary]");
|
||||
if (!root) return "";
|
||||
return (root.dataset.filterSummary || "").trim();
|
||||
}
|
||||
84
frontend/src/client/paliadin-late-poll.ts
Normal file
84
frontend/src/client/paliadin-late-poll.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
}
|
||||
|
||||
export interface LatePollOptions {
|
||||
turnId: string;
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
if (Date.now() - startedAt > maxDuration) {
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Transient network error; retry on next tick.
|
||||
}
|
||||
timer = window.setTimeout(tick, interval);
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
if (timer != null) window.clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
134
frontend/src/client/paliadin-render.ts
Normal file
134
frontend/src/client/paliadin-render.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Shared Paliadin response renderer — used by both the standalone
|
||||
// /paliadin page (client/paliadin.ts) and the inline drawer widget
|
||||
// (client/paliadin-widget.ts). Extracted from paliadin.ts so the
|
||||
// widget renders the same markdown + chips as the dedicated page
|
||||
// without re-implementing the pipeline.
|
||||
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
|
||||
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "/deadlines/" + id;
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "/projects/" + id;
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "/appointments/" + id;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function chipLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "Frist öffnen";
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "Akte ansehen";
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "Termin öffnen";
|
||||
default:
|
||||
return "öffnen";
|
||||
}
|
||||
}
|
||||
|
||||
function renderBlocks(escapedHtml: string): string {
|
||||
const out: string[] = [];
|
||||
let listItems: string[] = [];
|
||||
let paraLines: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length === 0) return;
|
||||
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
|
||||
listItems = [];
|
||||
};
|
||||
const flushPara = () => {
|
||||
if (paraLines.length === 0) return;
|
||||
out.push(`<p>${paraLines.join("<br>")}</p>`);
|
||||
paraLines = [];
|
||||
};
|
||||
|
||||
for (const rawLine of escapedHtml.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "") {
|
||||
flushList();
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
let m: RegExpMatchArray | null;
|
||||
if ((m = line.match(/^###\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h3>${m[1]}</h3>`);
|
||||
} else if ((m = line.match(/^##\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h2>${m[1]}</h2>`);
|
||||
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
|
||||
flushPara();
|
||||
listItems.push(m[1]);
|
||||
} else if (line.match(/^---+$/)) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<hr>`);
|
||||
} else {
|
||||
flushList();
|
||||
paraLines.push(line);
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
flushPara();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
export function renderResponseHTML(raw: string): string {
|
||||
let html = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
const chipHTML: string[] = [];
|
||||
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
let rendered = "";
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
} else if (chipKind === "nav") {
|
||||
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
} else if (chipKind === "filter") {
|
||||
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
if (!rendered) return "";
|
||||
chipHTML.push(rendered);
|
||||
return `CHIP${chipHTML.length - 1}`;
|
||||
});
|
||||
|
||||
html = renderBlocks(html);
|
||||
|
||||
html = html.replace(MD_LINK_RE, (_m, text, url) => {
|
||||
const ext = url.startsWith("http");
|
||||
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
|
||||
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
|
||||
});
|
||||
|
||||
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
|
||||
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||
|
||||
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
|
||||
|
||||
return html;
|
||||
}
|
||||
223
frontend/src/client/paliadin-starters.ts
Normal file
223
frontend/src/client/paliadin-starters.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// paliadin-starters.ts — per-route starter-prompt registry for the
|
||||
// Paliadin inline widget (t-paliad-161).
|
||||
//
|
||||
// The drawer's empty state renders the matching starter list. Click →
|
||||
// the prompt populates the textarea; if the prompt ends with `: ` it
|
||||
// stays in the textarea so the user finishes the sentence.
|
||||
//
|
||||
// Static registry by design. LLM-generated starters were considered and
|
||||
// rejected (latency, determinism, translatability — see design doc §5.2).
|
||||
|
||||
export interface Starter {
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
prompt_de: string;
|
||||
prompt_en: string;
|
||||
}
|
||||
|
||||
export const paliadinStarters: Record<string, Starter[]> = {
|
||||
"dashboard": [
|
||||
{
|
||||
label_de: "Heute",
|
||||
label_en: "Today",
|
||||
prompt_de: "Was steht heute an?",
|
||||
prompt_en: "What's on my plate today?",
|
||||
},
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Welche Fristen sind diese Woche?",
|
||||
prompt_en: "Which deadlines are this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Nächste Schritte",
|
||||
label_en: "Next steps",
|
||||
prompt_de: "Was sollte ich als nächstes erledigen?",
|
||||
prompt_en: "What should I tackle next?",
|
||||
},
|
||||
],
|
||||
"projects.detail": [
|
||||
{
|
||||
label_de: "Status der Akte",
|
||||
label_en: "Project status",
|
||||
prompt_de: "Was ist der aktuelle Status dieser Akte?",
|
||||
prompt_en: "What's the status of this project?",
|
||||
},
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Was steht für diese Akte diese Woche an?",
|
||||
prompt_en: "What's on for this project this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Frist anlegen",
|
||||
label_en: "Add a deadline",
|
||||
prompt_de: "Lege eine Frist für diese Akte an: ",
|
||||
prompt_en: "Add a deadline for this project: ",
|
||||
},
|
||||
],
|
||||
"projects.list": [
|
||||
{
|
||||
label_de: "Aktive Akten",
|
||||
label_en: "Active projects",
|
||||
prompt_de: "Welche Akten sind aktuell aktiv?",
|
||||
prompt_en: "Which projects are currently active?",
|
||||
},
|
||||
{
|
||||
label_de: "UPC-Akten",
|
||||
label_en: "UPC projects",
|
||||
prompt_de: "Zeige mir alle UPC-Akten.",
|
||||
prompt_en: "Show me all UPC projects.",
|
||||
},
|
||||
],
|
||||
"deadlines.detail": [
|
||||
{
|
||||
label_de: "Erkläre die Frist",
|
||||
label_en: "Explain this deadline",
|
||||
prompt_de: "Erkläre mir die Frist auf dieser Seite.",
|
||||
prompt_en: "Explain this deadline.",
|
||||
},
|
||||
{
|
||||
label_de: "Rechtsgrundlage",
|
||||
label_en: "Legal basis",
|
||||
prompt_de: "Welche Norm ist hier einschlägig?",
|
||||
prompt_en: "What's the relevant rule?",
|
||||
},
|
||||
{
|
||||
label_de: "Folgefristen",
|
||||
label_en: "Follow-on deadlines",
|
||||
prompt_de: "Welche Fristen ergeben sich aus dieser?",
|
||||
prompt_en: "What follow-on deadlines flow from this?",
|
||||
},
|
||||
],
|
||||
"deadlines.list": [
|
||||
{
|
||||
label_de: "Überfällige",
|
||||
label_en: "Overdue",
|
||||
prompt_de: "Welche Fristen sind überfällig?",
|
||||
prompt_en: "Which deadlines are overdue?",
|
||||
},
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Was steht diese Woche an?",
|
||||
prompt_en: "What's due this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Frist anlegen",
|
||||
label_en: "Add a deadline",
|
||||
prompt_de: "Lege eine Frist an: ",
|
||||
prompt_en: "Add a deadline: ",
|
||||
},
|
||||
],
|
||||
"appointments.list": [
|
||||
{
|
||||
label_de: "Heute",
|
||||
label_en: "Today",
|
||||
prompt_de: "Welche Termine habe ich heute?",
|
||||
prompt_en: "What appointments do I have today?",
|
||||
},
|
||||
{
|
||||
label_de: "Termin anlegen",
|
||||
label_en: "Add an appointment",
|
||||
prompt_de: "Lege einen Termin an: ",
|
||||
prompt_en: "Add an appointment: ",
|
||||
},
|
||||
],
|
||||
"appointments.detail": [
|
||||
{
|
||||
label_de: "Erkläre den Termin",
|
||||
label_en: "Explain this appointment",
|
||||
prompt_de: "Was ist auf diesem Termin zu klären?",
|
||||
prompt_en: "What needs to be addressed at this appointment?",
|
||||
},
|
||||
],
|
||||
"agenda": [
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Welche Termine und Fristen habe ich diese Woche?",
|
||||
prompt_en: "What appointments and deadlines do I have this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Konflikte prüfen",
|
||||
label_en: "Check conflicts",
|
||||
prompt_de: "Gibt es Terminkonflikte in dieser Ansicht?",
|
||||
prompt_en: "Are there scheduling conflicts in this view?",
|
||||
},
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Was steht diese Woche an?",
|
||||
prompt_en: "What's on for this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Überfällige",
|
||||
label_en: "Overdue",
|
||||
prompt_de: "Was ist überfällig?",
|
||||
prompt_en: "What's overdue?",
|
||||
},
|
||||
],
|
||||
"inbox": [
|
||||
{
|
||||
label_de: "Was wartet",
|
||||
label_en: "What's waiting",
|
||||
prompt_de: "Was wartet auf meine Genehmigung?",
|
||||
prompt_en: "What's waiting for my approval?",
|
||||
},
|
||||
],
|
||||
"tools.fristenrechner": [
|
||||
{
|
||||
label_de: "Erkläre den Rechner",
|
||||
label_en: "Explain the calculator",
|
||||
prompt_de: "Wie funktioniert der Fristenrechner?",
|
||||
prompt_en: "How does the deadline calculator work?",
|
||||
},
|
||||
{
|
||||
label_de: "Verfahrensablauf",
|
||||
label_en: "Proceeding flow",
|
||||
prompt_de: "Welche Folgefristen kommen typischerweise nach einer Klage?",
|
||||
prompt_en: "What deadlines typically follow a complaint?",
|
||||
},
|
||||
],
|
||||
"tools.kostenrechner": [
|
||||
{
|
||||
label_de: "Erkläre die Berechnung",
|
||||
label_en: "Explain the calculation",
|
||||
prompt_de: "Wie wird der Streitwert berechnet?",
|
||||
prompt_en: "How is the matter value calculated?",
|
||||
},
|
||||
],
|
||||
"glossary": [
|
||||
{
|
||||
label_de: "Begriff erklären",
|
||||
label_en: "Explain a term",
|
||||
prompt_de: "Erkläre mir den Begriff: ",
|
||||
prompt_en: "Explain the term: ",
|
||||
},
|
||||
],
|
||||
"courts": [
|
||||
{
|
||||
label_de: "UPC-Divisionen",
|
||||
label_en: "UPC divisions",
|
||||
prompt_de: "Zeige mir alle UPC Local Divisions.",
|
||||
prompt_en: "Show me all UPC Local Divisions.",
|
||||
},
|
||||
],
|
||||
// Fallback for unmapped routes — the textarea stays empty, the user
|
||||
// types from scratch.
|
||||
"_default": [
|
||||
{
|
||||
label_de: "Was kann ich für dich tun?",
|
||||
label_en: "What can I help with?",
|
||||
prompt_de: "",
|
||||
prompt_en: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function startersFor(routeName: string): Starter[] {
|
||||
return paliadinStarters[routeName] || paliadinStarters["_default"];
|
||||
}
|
||||
598
frontend/src/client/paliadin-widget.ts
Normal file
598
frontend/src/client/paliadin-widget.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
// paliadin-widget.ts — runtime for the inline Paliadin floating button +
|
||||
// slide-out drawer (t-paliad-161).
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. On DOMContentLoaded, fetch /api/me. If the email matches the
|
||||
// Paliadin owner gate (the same gate the standalone /paliadin
|
||||
// route uses) AND the route is one where the widget shows, reveal
|
||||
// the trigger button.
|
||||
// 2. Click trigger or press Cmd+J / Ctrl+J → open drawer + populate
|
||||
// starter prompts from paliadin-starters.ts.
|
||||
// 3. Submit form → POST /api/paliadin/turn with structured context
|
||||
// from computePaliadinContext() → consume the SSE stream → render
|
||||
// assistant bubble.
|
||||
// 4. Conversation history persists in localStorage per session id.
|
||||
//
|
||||
// Notes:
|
||||
// - Cmd+K is reserved for the global search palette (client/search.ts).
|
||||
// The widget uses Cmd+J / Ctrl+J as the keyboard trigger.
|
||||
// - The standalone /paliadin page's client (client/paliadin.ts) is
|
||||
// unchanged — this widget reuses /api/paliadin/turn but ships its
|
||||
// own UI and history bucket so the two surfaces stay independent.
|
||||
// - Visibility predicate mirrors paliadin-context.shouldSendContext()
|
||||
// so the widget never sends a turn from a route where it shouldn't
|
||||
// show.
|
||||
|
||||
import { initI18n, getLang, t } from "./i18n";
|
||||
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
|
||||
import { startersFor, type Starter } from "./paliadin-starters";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
interface MeResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
global_role?: string;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface TurnResponse {
|
||||
turn_id: string;
|
||||
sse_url: string;
|
||||
}
|
||||
|
||||
// Shared session key — the inline drawer and the standalone /paliadin
|
||||
// page must use the same browser-session id so both surfaces show the
|
||||
// same conversation. Migration on first run: if a legacy
|
||||
// `paliadin:widget:session` exists but the shared `paliadin:session`
|
||||
// does not, copy across so the user doesn't lose drawer state on the
|
||||
// rollover.
|
||||
const SESSION_KEY = "paliadin:session";
|
||||
const LEGACY_WIDGET_SESSION_KEY = "paliadin:widget:session";
|
||||
// History bucket — render-cache only; DB is source of truth (server
|
||||
// hydrates via /api/paliadin/history on every mount). The cache is keyed
|
||||
// by session id so a session reset gives a clean slate.
|
||||
const HISTORY_PREFIX = "paliadin:history:";
|
||||
|
||||
let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let drawerOpen = false;
|
||||
let activeStream: EventSource | null = null;
|
||||
let pending = false;
|
||||
// Late-response pollers per turn_id (see paliadin-late-poll.ts).
|
||||
const lateWidgetPolls = new Map<string, LatePollHandle>();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
const drawer = document.getElementById("paliadin-widget-drawer");
|
||||
if (!trigger || !drawer) return; // page didn't include the widget — skip silently
|
||||
initI18n();
|
||||
bootSession();
|
||||
void revealIfOwner();
|
||||
wireTrigger();
|
||||
wireDrawerControls();
|
||||
wireForm();
|
||||
wireKeyboardShortcut();
|
||||
});
|
||||
|
||||
function bootSession(): void {
|
||||
let s = localStorage.getItem(SESSION_KEY);
|
||||
if (!s) {
|
||||
// One-time migration: previous widget builds wrote
|
||||
// `paliadin:widget:session` instead of the shared key. Carry over
|
||||
// the existing id so the user keeps their conversation thread.
|
||||
const legacy = localStorage.getItem(LEGACY_WIDGET_SESSION_KEY);
|
||||
s = legacy || crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, s);
|
||||
}
|
||||
// Drop the legacy key now that we've migrated; harmless if it's
|
||||
// already absent.
|
||||
localStorage.removeItem(LEGACY_WIDGET_SESSION_KEY);
|
||||
sessionId = s;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function loadHistory(): void {
|
||||
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
|
||||
if (!stored) {
|
||||
history = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
history = Array.isArray(parsed) ? parsed.slice(-30) : [];
|
||||
} catch {
|
||||
history = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history.slice(-30)));
|
||||
} catch {
|
||||
/* localStorage quota or disabled — non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function revealIfOwner(): Promise<void> {
|
||||
if (!shouldSendContext(window.location.pathname)) return; // route excluded
|
||||
let me: MeResponse;
|
||||
try {
|
||||
const r = await fetch("/api/me", { credentials: "same-origin" });
|
||||
if (!r.ok) return;
|
||||
me = await r.json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// The server-side handler returns 404 for non-owners on every paliadin
|
||||
// route, so we don't need to know the owner email client-side. Probe
|
||||
// /api/paliadin/me-check (a 200/404 endpoint) — but that endpoint
|
||||
// doesn't exist; instead reuse the same reveal hook the sidebar uses,
|
||||
// which checks an `is_paliadin_owner` flag the /api/me payload includes
|
||||
// when paliadinSvc is wired and the caller matches.
|
||||
if (!isPaliadinOwner(me)) return;
|
||||
showTrigger();
|
||||
renderStarters();
|
||||
rehydrateHistory();
|
||||
// Refresh from DB in the background so cross-surface activity (a
|
||||
// turn typed on the standalone /paliadin page) shows up here without
|
||||
// a manual reload.
|
||||
void hydrateFromServer();
|
||||
}
|
||||
|
||||
function isPaliadinOwner(me: MeResponse): boolean {
|
||||
// Server-driven flag (matches the pattern client/sidebar.ts uses to
|
||||
// reveal the /paliadin sidebar entry). Fallback to email match only
|
||||
// if the flag is absent — this keeps the widget working on a server
|
||||
// build that hasn't shipped the flag yet.
|
||||
const flag = (me as unknown as { is_paliadin_owner?: boolean }).is_paliadin_owner;
|
||||
if (typeof flag === "boolean") return flag;
|
||||
// Fallback: hardcoded owner match. Same string as
|
||||
// services.PaliadinOwnerEmail in Go — keep in sync.
|
||||
return (me.email || "").toLowerCase() === "matthias.siebels@hoganlovells.com";
|
||||
}
|
||||
|
||||
function showTrigger(): void {
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
if (trigger) trigger.style.display = "";
|
||||
}
|
||||
|
||||
function wireTrigger(): void {
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
trigger?.addEventListener("click", () => openDrawer());
|
||||
}
|
||||
|
||||
function wireDrawerControls(): void {
|
||||
document.getElementById("paliadin-widget-close")?.addEventListener("click", () => closeDrawer());
|
||||
document.getElementById("paliadin-widget-scrim")?.addEventListener("click", () => closeDrawer());
|
||||
document.getElementById("paliadin-widget-reset")?.addEventListener("click", () => void resetSession());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && drawerOpen) {
|
||||
e.preventDefault();
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireKeyboardShortcut(): void {
|
||||
// Cmd+J / Ctrl+J — open or close the drawer. Cmd+K is reserved for
|
||||
// global search (client/search.ts), so we use J ("Junior assistant").
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const isCmdJ = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "j";
|
||||
if (!isCmdJ) return;
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
if (!trigger || trigger.style.display === "none") return; // widget not revealed
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (drawerOpen) {
|
||||
closeDrawer();
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openDrawer(): void {
|
||||
if (drawerOpen) return;
|
||||
drawerOpen = true;
|
||||
const drawer = document.getElementById("paliadin-widget-drawer");
|
||||
const scrim = document.getElementById("paliadin-widget-scrim");
|
||||
if (drawer) {
|
||||
drawer.style.display = "";
|
||||
drawer.dataset.open = "true";
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
if (scrim) {
|
||||
scrim.style.display = "";
|
||||
}
|
||||
// Force reflow so the slide-in animation runs (CSS transitions need a
|
||||
// flip from off-canvas to on-canvas).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
drawer?.offsetWidth;
|
||||
if (drawer) drawer.classList.add("paliadin-widget-drawer--visible");
|
||||
if (scrim) scrim.classList.add("paliadin-widget-scrim--visible");
|
||||
|
||||
refreshContextChip();
|
||||
renderStarters();
|
||||
// Pull the canonical conversation from the DB on every open so a
|
||||
// turn the user typed on /paliadin (or another tab) since the last
|
||||
// open is reflected here.
|
||||
void hydrateFromServer();
|
||||
setTimeout(() => {
|
||||
document.getElementById("paliadin-widget-input")?.focus();
|
||||
}, 60);
|
||||
}
|
||||
|
||||
function closeDrawer(): void {
|
||||
if (!drawerOpen) return;
|
||||
drawerOpen = false;
|
||||
const drawer = document.getElementById("paliadin-widget-drawer");
|
||||
const scrim = document.getElementById("paliadin-widget-scrim");
|
||||
drawer?.classList.remove("paliadin-widget-drawer--visible");
|
||||
scrim?.classList.remove("paliadin-widget-scrim--visible");
|
||||
// Wait for transition before display:none so the slide-out animates.
|
||||
setTimeout(() => {
|
||||
if (drawerOpen) return; // re-opened during transition
|
||||
if (drawer) {
|
||||
drawer.style.display = "none";
|
||||
drawer.dataset.open = "false";
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
if (scrim) scrim.style.display = "none";
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function refreshContextChip(): void {
|
||||
const chip = document.getElementById("paliadin-widget-context-chip");
|
||||
const value = document.getElementById("paliadin-widget-context-value");
|
||||
if (!chip || !value) return;
|
||||
const ctx = computePaliadinContext();
|
||||
if (!ctx) {
|
||||
chip.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const labelParts: string[] = [];
|
||||
if (ctx.primary_entity_type === "project") {
|
||||
labelParts.push(getLang() === "en" ? "Project" : "Akte");
|
||||
} else if (ctx.primary_entity_type === "deadline") {
|
||||
labelParts.push(getLang() === "en" ? "Deadline" : "Frist");
|
||||
} else if (ctx.primary_entity_type === "appointment") {
|
||||
labelParts.push(getLang() === "en" ? "Appointment" : "Termin");
|
||||
}
|
||||
labelParts.push(humanRouteName(ctx.route_name));
|
||||
value.textContent = labelParts.join(" · ");
|
||||
chip.style.display = "";
|
||||
}
|
||||
|
||||
function humanRouteName(route: string): string {
|
||||
// Prefer i18n key if present; fall back to a tidied form of the
|
||||
// route key itself.
|
||||
const key = "paliadin.widget.route." + route;
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) return translated;
|
||||
return route;
|
||||
}
|
||||
|
||||
function renderStarters(): void {
|
||||
const host = document.getElementById("paliadin-widget-starters");
|
||||
if (!host) return;
|
||||
const route = routeNameFor(window.location.pathname);
|
||||
const lang = getLang();
|
||||
const list = startersFor(route);
|
||||
host.innerHTML = "";
|
||||
list.forEach((s) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "paliadin-widget-starter";
|
||||
btn.textContent = lang === "en" ? s.label_en : s.label_de;
|
||||
btn.addEventListener("click", () => onStarterClick(s));
|
||||
host.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function onStarterClick(s: Starter): void {
|
||||
const lang = getLang();
|
||||
const promptText = lang === "en" ? s.prompt_en : s.prompt_de;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (!input) return;
|
||||
if (!promptText) {
|
||||
input.value = "";
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
// Prompts that end with ": " are intentional partial seeds — leave
|
||||
// the textarea so the user finishes the sentence.
|
||||
if (promptText.endsWith(": ")) {
|
||||
input.value = promptText;
|
||||
input.focus();
|
||||
input.setSelectionRange(promptText.length, promptText.length);
|
||||
return;
|
||||
}
|
||||
input.value = promptText;
|
||||
// Auto-send for fully-formed prompts.
|
||||
void sendTurn();
|
||||
}
|
||||
|
||||
function wireForm(): void {
|
||||
const form = document.getElementById("paliadin-widget-form") as HTMLFormElement | null;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (!form || !input) return;
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
void sendTurn();
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void sendTurn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTurn(): Promise<void> {
|
||||
if (pending) return;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
|
||||
hideEmpty();
|
||||
appendBubble("user", text);
|
||||
history.push({ role: "user", text, ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
|
||||
pending = true;
|
||||
setSendDisabled(true);
|
||||
const placeholder = appendBubble("assistant", "Paliadin denkt nach …");
|
||||
placeholder.dataset.streaming = "true";
|
||||
|
||||
let turnRes: TurnResponse;
|
||||
try {
|
||||
const ctx = computePaliadinContext();
|
||||
const r = await fetch("/api/paliadin/turn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
user_message: text,
|
||||
session_id: sessionId,
|
||||
page_origin: window.location.pathname + window.location.search,
|
||||
context: ctx ?? undefined,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
turnRes = await r.json();
|
||||
} catch {
|
||||
setBubbleText(placeholder, t("paliadin.error.upstream"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.dataset.streaming = "false";
|
||||
pending = false;
|
||||
setSendDisabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
activeStream = es;
|
||||
|
||||
let fullText = "";
|
||||
es.addEventListener("content", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
fullText = String(data.text || "");
|
||||
setBubbleText(placeholder, fullText);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
});
|
||||
es.addEventListener("end", () => {
|
||||
placeholder.dataset.streaming = "false";
|
||||
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.dataset.turnId = turnRes.turn_id;
|
||||
startWidgetLatePoll(turnRes.turn_id, placeholder);
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
/* heartbeat */
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupStream(): void {
|
||||
activeStream?.close();
|
||||
activeStream = null;
|
||||
pending = false;
|
||||
setSendDisabled(false);
|
||||
}
|
||||
|
||||
function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.get(turnId)?.cancel();
|
||||
const handle = pollForLateResponse({
|
||||
turnId,
|
||||
onLateResponse: (turn) => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
"paliadin-widget-bubble--error",
|
||||
"paliadin-widget-bubble--late-pending",
|
||||
);
|
||||
bubble.classList.add("paliadin-widget-bubble--late");
|
||||
setBubbleText(bubble, turn.response);
|
||||
// Append a small "(verspätet)" tag so the late arrival is visible.
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "paliadin-widget-bubble-late-tag";
|
||||
tag.textContent = " · " + t("paliadin.late.marker");
|
||||
bubble.appendChild(tag);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: turn.response,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function setSendDisabled(disabled: boolean): void {
|
||||
const btn = document.getElementById("paliadin-widget-send-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = disabled;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (input) input.disabled = disabled;
|
||||
}
|
||||
|
||||
function hideEmpty(): void {
|
||||
const empty = document.getElementById("paliadin-widget-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
}
|
||||
|
||||
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
|
||||
const body = document.createElement("div");
|
||||
body.className = "paliadin-widget-bubble-text";
|
||||
// Assistant bubbles get the same markdown + chip pipeline as the
|
||||
// standalone /paliadin page (client/paliadin-render.ts). User bubbles
|
||||
// stay plain text — no need to interpret the user's typed markup.
|
||||
if (role === "assistant") {
|
||||
body.innerHTML = renderResponseHTML(text);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
messages?.appendChild(wrap);
|
||||
if (messages) messages.scrollTop = messages.scrollHeight;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function setBubbleText(bubble: HTMLElement, text: string): void {
|
||||
const body = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (body) {
|
||||
const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
|
||||
if (isAssistant) {
|
||||
(body as HTMLElement).innerHTML = renderResponseHTML(text);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
}
|
||||
}
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
if (messages) messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function rehydrateHistory(): void {
|
||||
if (!history.length) return;
|
||||
hideEmpty();
|
||||
history.forEach((h) => appendBubble(h.role, h.text));
|
||||
}
|
||||
|
||||
// PaliadinTurnRow mirrors the JSON shape /api/paliadin/history returns
|
||||
// (services.PaliadinTurn). Fields we don't render yet (used_tools etc.)
|
||||
// are typed as unknown to keep the contract loose.
|
||||
interface PaliadinTurnRow {
|
||||
turn_id: string;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
user_message: string;
|
||||
response?: string | null;
|
||||
error_code?: string | null;
|
||||
}
|
||||
|
||||
// Hydrate from the DB on every mount. Crash-resistant: a typed turn
|
||||
// always lands in paliad.paliadin_turns, so even if the user closes
|
||||
// the tab mid-flight or the device dies, the next mount picks it up.
|
||||
//
|
||||
// Reconciliation: DB > localStorage. If the DB returns rows, we trust
|
||||
// them entirely and overwrite the cache. If the DB call fails or
|
||||
// returns empty, we keep whatever's in localStorage (offline cushion).
|
||||
async function hydrateFromServer(): Promise<void> {
|
||||
let rows: PaliadinTurnRow[] = [];
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
|
||||
{ credentials: "same-origin" },
|
||||
);
|
||||
if (!r.ok) return;
|
||||
const body = (await r.json()) as PaliadinTurnRow[] | null;
|
||||
rows = Array.isArray(body) ? body : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!rows.length) return;
|
||||
|
||||
// Project DB rows into the {role, text, ts} shape the cache + render
|
||||
// path expect. Each turn becomes two entries (user prompt then
|
||||
// assistant response). Skip turns with no response (in-flight, or
|
||||
// errored without a recovery) so the bubble doesn't show
|
||||
// half-rendered placeholders on reload.
|
||||
const reconstructed: HistoryEntry[] = [];
|
||||
for (const row of rows) {
|
||||
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
|
||||
if (typeof row.response === "string" && row.response.length > 0) {
|
||||
reconstructed.push({ role: "assistant", text: row.response, ts: row.started_at });
|
||||
}
|
||||
}
|
||||
history = reconstructed;
|
||||
saveHistory();
|
||||
|
||||
// Re-render: clear the message list + replay the canonical history.
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
const empty = document.getElementById("paliadin-widget-empty");
|
||||
if (messages) {
|
||||
// Strip every prior bubble but keep the empty-state placeholder so
|
||||
// it can be hidden by hideEmpty() if we end up rendering anything.
|
||||
messages.querySelectorAll(".paliadin-widget-bubble").forEach((n) => n.remove());
|
||||
if (empty) empty.style.display = "none";
|
||||
history.forEach((h) => appendBubble(h.role, h.text));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSession(): Promise<void> {
|
||||
if (!confirm(t("paliadin.widget.reset.confirm"))) return;
|
||||
history = [];
|
||||
saveHistory();
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
if (messages) {
|
||||
messages.innerHTML = "";
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "paliadin-widget-empty";
|
||||
empty.id = "paliadin-widget-empty";
|
||||
const label = document.createElement("p");
|
||||
label.className = "paliadin-widget-empty-prompt";
|
||||
label.setAttribute("data-i18n", "paliadin.widget.empty");
|
||||
label.textContent = t("paliadin.widget.empty");
|
||||
const starters = document.createElement("div");
|
||||
starters.className = "paliadin-widget-starters";
|
||||
starters.id = "paliadin-widget-starters";
|
||||
empty.appendChild(label);
|
||||
empty.appendChild(starters);
|
||||
messages.appendChild(empty);
|
||||
renderStarters();
|
||||
}
|
||||
try {
|
||||
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
508
frontend/src/client/paliadin.ts
Normal file
508
frontend/src/client/paliadin.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { initI18n, getLang, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// History lives in localStorage under "paliadin:history:<sessionId>"
|
||||
// — design §0.5.4 session-only persistence.
|
||||
//
|
||||
// SSE consumer subscribes to `event: meta`, `event: content`,
|
||||
// `event: end`, `event: error`, `event: ping`. Backend currently
|
||||
// emits one `content` blob per turn (real chunked streaming is
|
||||
// production-v1; PoC simulates with a typewriter effect).
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
meta?: {
|
||||
used_tools?: string[];
|
||||
rows_seen?: number[];
|
||||
classifier_tag?: string;
|
||||
duration_ms?: number;
|
||||
chip_count?: number;
|
||||
};
|
||||
ts: string; // ISO
|
||||
}
|
||||
|
||||
const SESSION_KEY = "paliadin:session";
|
||||
const HISTORY_PREFIX = "paliadin:history:";
|
||||
|
||||
let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let currentEventSource: EventSource | null = null;
|
||||
let currentTurnId: string | null = null;
|
||||
// Late-response polls keyed by turn_id. Each entry runs until the
|
||||
// response arrives or the 10-min cap expires. Stays alive across
|
||||
// turns — m can keep chatting while we wait for the slow one.
|
||||
const latePolls = new Map<string, LatePollHandle>();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
bootSession();
|
||||
wireForm();
|
||||
wireStarters();
|
||||
wireReset();
|
||||
renderHistory();
|
||||
// Pull the canonical conversation from the DB so a turn typed in the
|
||||
// inline drawer (which shares this session id) shows up here on
|
||||
// mount. DB > localStorage when both have data.
|
||||
void hydrateFromServer();
|
||||
});
|
||||
|
||||
function bootSession(): void {
|
||||
let s = localStorage.getItem(SESSION_KEY);
|
||||
if (!s) {
|
||||
s = crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, s);
|
||||
}
|
||||
sessionId = s;
|
||||
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
|
||||
if (stored) {
|
||||
try {
|
||||
history = JSON.parse(stored);
|
||||
} catch {
|
||||
history = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireForm(): void {
|
||||
const form = document.getElementById("paliadin-form") as HTMLFormElement | null;
|
||||
const input = document.getElementById("paliadin-input") as HTMLTextAreaElement | null;
|
||||
if (!form || !input) return;
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
sendTurn(text);
|
||||
});
|
||||
|
||||
// Enter sends; Shift+Enter inserts newline.
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.dispatchEvent(new Event("submit"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireStarters(): void {
|
||||
const starters = document.querySelectorAll<HTMLButtonElement>(".paliadin-starter");
|
||||
starters.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const lang = getLang();
|
||||
const promptText = lang === "en"
|
||||
? btn.dataset.promptEn || btn.textContent?.trim() || ""
|
||||
: btn.dataset.promptDe || btn.textContent?.trim() || "";
|
||||
if (promptText) sendTurn(promptText);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireReset(): void {
|
||||
const btn = document.getElementById("paliadin-reset");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
history = [];
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
try {
|
||||
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
|
||||
} catch {
|
||||
// Reset failure is non-fatal — the next turn will spin up a fresh pane anyway.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTurn(text: string): Promise<void> {
|
||||
// Hide empty state on first send.
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
|
||||
// Append user bubble.
|
||||
history.push({ role: "user", text, ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
appendBubble("user", text);
|
||||
|
||||
// Insert placeholder assistant bubble.
|
||||
const placeholder = appendBubble("assistant", "");
|
||||
placeholder.dataset.streaming = "true";
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent = "Paliadin denkt nach …";
|
||||
|
||||
toggleStopButton(true);
|
||||
|
||||
// Kick off the turn.
|
||||
let turnRes: { turn_id: string; sse_url: string };
|
||||
try {
|
||||
const r = await fetch("/api/paliadin/turn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
user_message: text,
|
||||
session_id: sessionId,
|
||||
page_origin: "/paliadin",
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
turnRes = await r.json();
|
||||
} catch (err) {
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
t("paliadin.error.upstream");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
toggleStopButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
currentTurnId = turnRes.turn_id;
|
||||
|
||||
// Open SSE.
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const text = String(data.text || "");
|
||||
// Cache the full text on the bubble so finishBubble can render the
|
||||
// complete response even when the typewriter is mid-flight when end
|
||||
// arrives. textContent reflects only what's been typed so far and
|
||||
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
|
||||
// saw "## Proje" instead of the full 1408-byte body).
|
||||
placeholder.dataset.fullText = text;
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
// Save the raw Markdown body (with [#deadline-OPEN:...] chip markers
|
||||
// intact), not the rendered textContent. Otherwise on reload the
|
||||
// chip-anchor text replaces the markers and renderResponseHTML can
|
||||
// no longer reconstruct the links (m, 2026-05-08 14:11 — links
|
||||
// disappeared on second load).
|
||||
text: placeholder.dataset.fullText ?? getBubbleText(placeholder),
|
||||
meta: {
|
||||
used_tools: data.used_tools,
|
||||
rows_seen: data.rows_seen,
|
||||
classifier_tag: data.classifier_tag,
|
||||
duration_ms: data.duration_ms,
|
||||
chip_count: data.chip_count,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.dataset.errorText = errText;
|
||||
if (currentTurnId) {
|
||||
placeholder.dataset.turnId = currentTurnId;
|
||||
startLatePoll(currentTurnId, placeholder);
|
||||
}
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
es.addEventListener("ping", () => {
|
||||
// heartbeat — no-op
|
||||
});
|
||||
}
|
||||
|
||||
// Server emits SSE error events as JSON `{code, message}`. Map known
|
||||
// codes to localised, user-friendly text; fall through to a generic
|
||||
// "connection lost" for anything we don't recognise (including raw
|
||||
// EventSource transport errors where data is absent).
|
||||
function friendlyErrorMessage(data: unknown): string {
|
||||
if (typeof data !== "string" || data === "") {
|
||||
return t("paliadin.error.connection_lost");
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { code?: string };
|
||||
switch (parsed.code) {
|
||||
case "tmux_unavailable":
|
||||
// Local PoC path: paliad is running on a host without tmux/claude
|
||||
// (typically the legacy laptop-only build).
|
||||
return t("paliadin.error.local_only");
|
||||
case "mriver_unreachable":
|
||||
// t-paliad-151: prod path's mRiver is offline (laptop asleep, off
|
||||
// tailnet, or paliadin-shim missing).
|
||||
return t("paliadin.error.mriver_unreachable");
|
||||
case "shim_auth_failed":
|
||||
// SSH key wrong or authorized_keys drifted.
|
||||
return t("paliadin.error.shim_auth_failed");
|
||||
case "shim_error":
|
||||
case "bootstrap_failed":
|
||||
// Generic remote shim failure or system-prompt bootstrap error.
|
||||
return t("paliadin.error.shim_error");
|
||||
case "timeout":
|
||||
return t("paliadin.error.timeout");
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to the generic connection-lost message
|
||||
// rather than leaking a raw payload into the bubble.
|
||||
}
|
||||
return t("paliadin.error.connection_lost");
|
||||
}
|
||||
|
||||
function cleanupTurn(): void {
|
||||
if (currentEventSource) {
|
||||
currentEventSource.close();
|
||||
currentEventSource = null;
|
||||
}
|
||||
currentTurnId = null;
|
||||
toggleStopButton(false);
|
||||
}
|
||||
|
||||
function toggleStopButton(streaming: boolean): void {
|
||||
const send = document.getElementById("paliadin-send") as HTMLButtonElement | null;
|
||||
const stop = document.getElementById("paliadin-stop") as HTMLButtonElement | null;
|
||||
if (send) send.style.display = streaming ? "none" : "";
|
||||
if (stop) {
|
||||
stop.style.display = streaming ? "" : "none";
|
||||
stop.onclick = () => {
|
||||
cleanupTurn();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
||||
const stream = document.getElementById("paliadin-stream")!;
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "paliadin-bubble paliadin-bubble--" + role;
|
||||
bubble.innerHTML = `
|
||||
<div class="paliadin-bubble-role">${role === "user" ? "Du" : "Paliadin"}</div>
|
||||
<div class="paliadin-bubble-text"></div>
|
||||
<div class="paliadin-bubble-meta" style="display:none"></div>
|
||||
`;
|
||||
bubble.querySelector(".paliadin-bubble-text")!.textContent = text;
|
||||
stream.appendChild(bubble);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
return bubble;
|
||||
}
|
||||
|
||||
// typewriter incrementally fills the bubble's text node so a one-shot
|
||||
// content blob feels like streaming. ~5 ms per character; fast enough
|
||||
// to keep up with even a 4k-char response.
|
||||
function typewriter(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text")!;
|
||||
node.textContent = "";
|
||||
let i = 0;
|
||||
const speed = 6;
|
||||
const tick = () => {
|
||||
if (bubble.dataset.streaming !== "true") {
|
||||
// Streaming finished — finishBubble has already rendered the full
|
||||
// Markdown via dataset.fullText. Return without writing so we
|
||||
// don't replace the rendered HTML with raw text on a delayed tick.
|
||||
return;
|
||||
}
|
||||
if (i >= text.length) return;
|
||||
const next = Math.min(i + 8, text.length);
|
||||
node.textContent = text.slice(0, next);
|
||||
i = next;
|
||||
const stream = document.getElementById("paliadin-stream")!;
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
setTimeout(tick, speed);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function getBubbleText(bubble: HTMLElement): string {
|
||||
return bubble.querySelector(".paliadin-bubble-text")?.textContent || "";
|
||||
}
|
||||
|
||||
// finishBubble parses the response for citation markers + tool-use
|
||||
// evidence and renders both. Markers found in the text get replaced
|
||||
// by anchor buttons; the meta row at the bottom shows
|
||||
// "ran search_my_deadlines (3 results)".
|
||||
function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
|
||||
// Prefer the full text cached on the bubble at content-event time;
|
||||
// textContent may still reflect the typewriter's partial state.
|
||||
const raw = bubble.dataset.fullText ?? textNode.textContent ?? "";
|
||||
textNode.innerHTML = renderResponseHTML(raw);
|
||||
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
if (metaEl) {
|
||||
const tools = (data.used_tools || []) as string[];
|
||||
const rows = (data.rows_seen || []) as number[];
|
||||
if (tools.length > 0) {
|
||||
const parts = tools.map((t, i) => {
|
||||
const r = rows[i];
|
||||
return r != null ? `${t} (${r})` : t;
|
||||
});
|
||||
metaEl.innerHTML = "▸ " + parts.join(" · ");
|
||||
metaEl.style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
latePolls.get(turnId)?.cancel();
|
||||
const handle = pollForLateResponse({
|
||||
turnId,
|
||||
onLateResponse: (turn) => {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
},
|
||||
});
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--late");
|
||||
bubble.dataset.fullText = turn.response;
|
||||
bubble.dataset.streaming = "false";
|
||||
finishBubble(bubble, {
|
||||
used_tools: turn.used_tools,
|
||||
rows_seen: turn.rows_seen,
|
||||
classifier_tag: turn.classifier_tag,
|
||||
duration_ms: turn.duration_ms,
|
||||
chip_count: turn.chip_count,
|
||||
});
|
||||
// Inject a small "(verspätet)" marker into the meta row so it's
|
||||
// visible at a glance that this bubble was patched after the fact.
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
if (metaEl) {
|
||||
const lateTag = document.createElement("span");
|
||||
lateTag.className = "paliadin-bubble-late-tag";
|
||||
lateTag.textContent = " · " + t("paliadin.late.marker");
|
||||
metaEl.appendChild(lateTag);
|
||||
metaEl.style.display = "";
|
||||
}
|
||||
// Persist so a reload shows the late response in place of the error.
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: turn.response,
|
||||
meta: {
|
||||
used_tools: turn.used_tools,
|
||||
rows_seen: turn.rows_seen,
|
||||
classifier_tag: turn.classifier_tag ?? undefined,
|
||||
duration_ms: turn.duration_ms ?? undefined,
|
||||
chip_count: turn.chip_count,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
|
||||
}
|
||||
|
||||
// PaliadinTurnRow mirrors the JSON returned by /api/paliadin/history
|
||||
// (services.PaliadinTurn). Fields we don't render yet are skipped.
|
||||
interface PaliadinTurnRow {
|
||||
turn_id: string;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
user_message: string;
|
||||
response?: string | null;
|
||||
used_tools?: string[] | null;
|
||||
rows_seen?: number[] | null;
|
||||
classifier_tag?: string | null;
|
||||
duration_ms?: number | null;
|
||||
chip_count?: number | null;
|
||||
}
|
||||
|
||||
// Hydrate from /api/paliadin/history, replacing the localStorage cache
|
||||
// when the DB returns rows. Fail-quiet on network / auth errors —
|
||||
// localStorage is a perfectly good offline fallback.
|
||||
async function hydrateFromServer(): Promise<void> {
|
||||
let rows: PaliadinTurnRow[] = [];
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
|
||||
{ credentials: "same-origin" },
|
||||
);
|
||||
if (!r.ok) return;
|
||||
const body = (await r.json()) as PaliadinTurnRow[] | null;
|
||||
rows = Array.isArray(body) ? body : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!rows.length) return;
|
||||
const reconstructed: HistoryEntry[] = [];
|
||||
for (const row of rows) {
|
||||
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
|
||||
if (typeof row.response === "string" && row.response.length > 0) {
|
||||
reconstructed.push({
|
||||
role: "assistant",
|
||||
text: row.response,
|
||||
ts: row.started_at,
|
||||
meta: {
|
||||
used_tools: row.used_tools ?? undefined,
|
||||
rows_seen: row.rows_seen ?? undefined,
|
||||
classifier_tag: row.classifier_tag ?? undefined,
|
||||
duration_ms: row.duration_ms ?? undefined,
|
||||
chip_count: row.chip_count ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
history = reconstructed;
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function renderHistory(): void {
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (!stream) return;
|
||||
// Clear non-empty bubbles, keep the empty-state.
|
||||
Array.from(stream.children).forEach((el) => {
|
||||
if (!el.classList.contains("paliadin-empty")) el.remove();
|
||||
});
|
||||
if (history.length === 0) {
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
history.forEach((h) => {
|
||||
const bubble = appendBubble(h.role, h.text);
|
||||
if (h.role === "assistant" && h.meta) {
|
||||
bubble.dataset.streaming = "false";
|
||||
finishBubble(bubble, {
|
||||
used_tools: h.meta.used_tools,
|
||||
rows_seen: h.meta.rows_seen,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface ProjectFormState {
|
||||
grantDate: string;
|
||||
court: string;
|
||||
caseNumber: string;
|
||||
ourSide: string;
|
||||
}
|
||||
|
||||
let parentCandidates: ProjectMini[] = [];
|
||||
@@ -178,6 +179,17 @@ export function readPayload(
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
if (desc) payload.description = desc;
|
||||
else if (!opts.omitEmpty) payload.description = "";
|
||||
@@ -214,6 +226,8 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-grant-date").value = isoToDate(p.grant_date as string | null | undefined);
|
||||
get("project-court").value = String(p.court ?? "");
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t, tDyn } from "./i18n";
|
||||
|
||||
// Tree view of paliad.projects rendered into a container element. Reads
|
||||
// /api/projects/tree once on first init and caches the response. Top two
|
||||
// levels expand by default; deeper nodes start collapsed and toggle via the
|
||||
// chevron.
|
||||
// Tree view of paliad.projects rendered into a container element.
|
||||
// t-paliad-149 redesign: tree fetches with the orchestrator's chip / search
|
||||
// state encoded as query params on /api/projects/tree, so the cache invalidates
|
||||
// when the orchestrator calls refreshProjectTree({ params }).
|
||||
|
||||
export interface ProjectTreeNode {
|
||||
id: string;
|
||||
@@ -17,10 +17,22 @@ export interface ProjectTreeNode {
|
||||
matter_number?: string | null;
|
||||
open_deadlines: number;
|
||||
overdue_deadlines: number;
|
||||
// t-paliad-149: subtree-aggregated counts populated when ?subtree_counts=true
|
||||
// (the new default). Per-node fields above stay populated regardless.
|
||||
open_deadlines_subtree?: number;
|
||||
overdue_deadlines_subtree?: number;
|
||||
// t-paliad-149: pin state on /api/projects/tree response.
|
||||
pinned?: boolean;
|
||||
// t-paliad-149: greyed-ancestor flag (Scope=Mine / Scope=Pinned).
|
||||
inherited_visibility?: boolean;
|
||||
// t-paliad-149: search match kind. Empty when no search active.
|
||||
match_kind?: "self" | "ancestor" | "descendant" | "";
|
||||
children: ProjectTreeNode[];
|
||||
}
|
||||
|
||||
let cache: ProjectTreeNode[] | null = null;
|
||||
let cacheParams = "";
|
||||
let useSubtreeCounts = true;
|
||||
const expandedKey = "paliad.projectTree.expanded";
|
||||
let expanded = loadExpanded();
|
||||
|
||||
@@ -121,8 +133,12 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
const statusLabel = tDyn(`projects.filter.status.${node.status}`) || node.status;
|
||||
const cm = clientMatter(node);
|
||||
const ref = node.reference || "";
|
||||
const overdue = node.overdue_deadlines;
|
||||
const openCount = node.open_deadlines;
|
||||
|
||||
// Subtree-aggregated counts when available, per-node otherwise (the
|
||||
// chip-row toolbar can flip the orchestrator's subtree_counts param).
|
||||
const useSubtree = useSubtreeCounts && (node.open_deadlines_subtree !== undefined);
|
||||
const overdue = useSubtree ? (node.overdue_deadlines_subtree ?? 0) : node.overdue_deadlines;
|
||||
const openCount = useSubtree ? (node.open_deadlines_subtree ?? 0) : node.open_deadlines;
|
||||
|
||||
const toggle = hasChildren
|
||||
? `<button class="projekt-tree-toggle${open ? " is-open" : ""}" type="button" aria-label="${esc(t("projects.tree.toggle") || "Aufklappen / Zuklappen")}">${chevron}</button>`
|
||||
@@ -130,13 +146,24 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
|
||||
const icon = typeIcons[node.type] || typeIcons.project;
|
||||
|
||||
// Pin star — always-visible (touch-friendly per design §4.6).
|
||||
const pinned = !!node.pinned;
|
||||
const pinLabel = pinned
|
||||
? (t("projects.tree.unpin") || "Pin entfernen")
|
||||
: (t("projects.tree.pin") || "Anpinnen");
|
||||
const pinStar = `<button class="projekt-tree-pin${pinned ? " is-pinned" : ""}" type="button" data-action="pin" aria-label="${esc(pinLabel)}" title="${esc(pinLabel)}">${pinned ? starFilled : starOutline}</button>`;
|
||||
|
||||
const subtreeHint = useSubtree
|
||||
? (t("projects.tree.deadlines.subtree.tooltip") || "Inkl. Unterprojekte")
|
||||
: (t("projects.tree.deadlines.direct.tooltip") || "Nur direkt");
|
||||
|
||||
let badges = "";
|
||||
if (overdue > 0) {
|
||||
const label = t("projects.tree.deadlines.overdue") || "überfällig";
|
||||
const label = (t("projects.tree.deadlines.overdue") || "überfällig") + " — " + subtreeHint;
|
||||
badges += `<span class="projekt-tree-badge projekt-tree-badge-overdue" title="${esc(label)}">${overdue}</span>`;
|
||||
}
|
||||
if (openCount > 0 && overdue === 0) {
|
||||
const label = t("projects.tree.deadlines.open") || "offene Fristen";
|
||||
const label = (t("projects.tree.deadlines.open") || "offene Fristen") + " — " + subtreeHint;
|
||||
badges += `<span class="projekt-tree-badge projekt-tree-badge-open" title="${esc(label)}">${openCount}</span>`;
|
||||
}
|
||||
|
||||
@@ -148,11 +175,24 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
? `<ul class="projekt-tree-children" role="group">${node.children.map((c) => renderNode(c, depth + 1)).join("")}</ul>`
|
||||
: "";
|
||||
|
||||
// Modifier classes for the row:
|
||||
// - is-inherited: greyed-ancestor under Scope=Mine / Scope=Pinned (design §3.3)
|
||||
// - is-match-self / is-match-ancestor / is-match-descendant: search highlighting
|
||||
const modifiers: string[] = [];
|
||||
if (node.inherited_visibility) modifiers.push("is-inherited");
|
||||
if (node.match_kind === "self") modifiers.push("is-match-self");
|
||||
if (node.match_kind === "ancestor") modifiers.push("is-match-ancestor");
|
||||
if (node.match_kind === "descendant") modifiers.push("is-match-descendant");
|
||||
const rowClass = ["projekt-tree-row", ...modifiers].join(" ");
|
||||
const inheritedHint = node.inherited_visibility
|
||||
? ` title="${esc(t("projects.tree.inherited.context") || "Sichtbar wegen Unterprojekt")}"`
|
||||
: "";
|
||||
|
||||
return (
|
||||
`<li class="projekt-tree-node" data-id="${esc(node.id)}" data-depth="${depth}" role="treeitem"` +
|
||||
(hasChildren ? ` aria-expanded="${open ? "true" : "false"}"` : "") +
|
||||
`>` +
|
||||
`<div class="projekt-tree-row" tabindex="0">` +
|
||||
`<div class="${rowClass}" tabindex="0"${inheritedHint}>` +
|
||||
toggle +
|
||||
`<span class="projekt-tree-icon projekt-tree-icon-${esc(node.type)}">${icon}</span>` +
|
||||
`<span class="projekt-tree-title">${esc(node.title)}</span>` +
|
||||
@@ -161,16 +201,32 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
`<span class="projekt-tree-spacer"></span>` +
|
||||
badges +
|
||||
`<span class="projekt-tree-status entity-status-chip entity-status-${esc(node.status)}">${esc(statusLabel)}</span>` +
|
||||
pinStar +
|
||||
`</div>` +
|
||||
childMarkup +
|
||||
`</li>`
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchTree(container: HTMLElement): Promise<ProjectTreeNode[] | null> {
|
||||
if (cache) return cache;
|
||||
// Lucide-style filled / outline star for the pin toggle.
|
||||
const starFilled =
|
||||
`<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true">` +
|
||||
`<polygon points="12 2 15 9 22 10 17 15 18 22 12 18 6 22 7 15 2 10 9 9"/>` +
|
||||
`</svg>`;
|
||||
const starOutline =
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true">` +
|
||||
`<polygon points="12 2 15 9 22 10 17 15 18 22 12 18 6 22 7 15 2 10 9 9"/>` +
|
||||
`</svg>`;
|
||||
|
||||
// fetchTree calls /api/projects/tree with the orchestrator's query params.
|
||||
// The cache key is the params string — any change reloads. Pass an empty
|
||||
// URLSearchParams for the legacy "all visible projects" call.
|
||||
async function fetchTree(container: HTMLElement, params: URLSearchParams): Promise<ProjectTreeNode[] | null> {
|
||||
const key = params.toString();
|
||||
if (cache && cacheParams === key) return cache;
|
||||
const url = key ? `/api/projects/tree?${key}` : "/api/projects/tree";
|
||||
try {
|
||||
const resp = await fetch("/api/projects/tree");
|
||||
const resp = await fetch(url);
|
||||
if (resp.status === 503) {
|
||||
container.innerHTML = `<div class="projekt-tree-unavailable" data-i18n="projects.unavailable">${esc(t("projects.unavailable") || "Projektverwaltung zurzeit nicht verfügbar")}</div>`;
|
||||
return null;
|
||||
@@ -180,6 +236,7 @@ async function fetchTree(container: HTMLElement): Promise<ProjectTreeNode[] | nu
|
||||
return null;
|
||||
}
|
||||
cache = (await resp.json()) as ProjectTreeNode[];
|
||||
cacheParams = key;
|
||||
return cache;
|
||||
} catch {
|
||||
container.innerHTML = `<div class="projekt-tree-error">${esc(t("projects.tree.error") || "Baumansicht konnte nicht geladen werden.")}</div>`;
|
||||
@@ -192,6 +249,7 @@ function attachHandlers(container: HTMLElement) {
|
||||
const row = node.querySelector<HTMLElement>(":scope > .projekt-tree-row");
|
||||
if (!row) return;
|
||||
const toggle = row.querySelector<HTMLElement>(".projekt-tree-toggle");
|
||||
const pinBtn = row.querySelector<HTMLElement>(".projekt-tree-pin");
|
||||
const id = node.dataset.id!;
|
||||
const depth = Number(node.dataset.depth || "0");
|
||||
|
||||
@@ -199,9 +257,23 @@ function attachHandlers(container: HTMLElement) {
|
||||
window.location.href = `/projects/${id}`;
|
||||
};
|
||||
|
||||
// Pin toggle — POST/DELETE, optimistic update on the row, hard refresh
|
||||
// of the cache afterwards so subtree counts (if visible) stay coherent.
|
||||
if (pinBtn) {
|
||||
pinBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const data = cache && findById(cache, id);
|
||||
if (!data) return;
|
||||
void togglePin(data, pinBtn);
|
||||
});
|
||||
}
|
||||
|
||||
if (toggle && toggle.classList.contains("is-leaf")) {
|
||||
// No children — entire row navigates.
|
||||
row.addEventListener("click", navigate);
|
||||
row.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".projekt-tree-pin")) return;
|
||||
navigate();
|
||||
});
|
||||
row.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -223,8 +295,10 @@ function attachHandlers(container: HTMLElement) {
|
||||
}
|
||||
|
||||
row.addEventListener("click", (e) => {
|
||||
// Clicking the row (but not the toggle) navigates.
|
||||
if ((e.target as HTMLElement).closest(".projekt-tree-toggle")) return;
|
||||
// Clicking the row (but not the toggle / pin) navigates.
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".projekt-tree-toggle")) return;
|
||||
if (target.closest(".projekt-tree-pin")) return;
|
||||
navigate();
|
||||
});
|
||||
row.addEventListener("keydown", (e) => {
|
||||
@@ -243,6 +317,41 @@ function attachHandlers(container: HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
async function togglePin(node: ProjectTreeNode, btn: HTMLElement) {
|
||||
const wasPinned = !!node.pinned;
|
||||
// Optimistic flip — visually toggle the star immediately so the user
|
||||
// sees feedback even on slow networks.
|
||||
node.pinned = !wasPinned;
|
||||
btn.classList.toggle("is-pinned", node.pinned);
|
||||
btn.innerHTML = node.pinned ? starFilled : starOutline;
|
||||
const newLabel = node.pinned
|
||||
? (t("projects.tree.unpin") || "Pin entfernen")
|
||||
: (t("projects.tree.pin") || "Anpinnen");
|
||||
btn.setAttribute("aria-label", newLabel);
|
||||
btn.setAttribute("title", newLabel);
|
||||
|
||||
try {
|
||||
const method = wasPinned ? "DELETE" : "POST";
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(node.id)}/pin`, { method });
|
||||
if (!resp.ok && resp.status !== 201 && resp.status !== 204) {
|
||||
// Revert on failure.
|
||||
node.pinned = wasPinned;
|
||||
btn.classList.toggle("is-pinned", node.pinned);
|
||||
btn.innerHTML = node.pinned ? starFilled : starOutline;
|
||||
return;
|
||||
}
|
||||
// Success — invalidate cache so the next chip-driven refresh
|
||||
// (e.g. user clicks "Angepinnt") gets fresh server state.
|
||||
cache = null;
|
||||
cacheParams = "";
|
||||
} catch {
|
||||
// Revert on network error.
|
||||
node.pinned = wasPinned;
|
||||
btn.classList.toggle("is-pinned", node.pinned);
|
||||
btn.innerHTML = node.pinned ? starFilled : starOutline;
|
||||
}
|
||||
}
|
||||
|
||||
function findById(nodes: ProjectTreeNode[], id: string): ProjectTreeNode | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
@@ -253,6 +362,7 @@ function findById(nodes: ProjectTreeNode[], id: string): ProjectTreeNode | null
|
||||
}
|
||||
|
||||
let mountContainer: HTMLElement | null = null;
|
||||
let mountParams: URLSearchParams = new URLSearchParams();
|
||||
|
||||
function rerender() {
|
||||
if (!mountContainer || !cache) return;
|
||||
@@ -265,20 +375,35 @@ function rerender() {
|
||||
attachHandlers(mountContainer);
|
||||
}
|
||||
|
||||
export async function initProjectTree(container: HTMLElement) {
|
||||
// initProjectTree mounts the tree at `container`. The optional params encode
|
||||
// the orchestrator's chip / search state — see /api/projects/tree handler.
|
||||
// Empty params → legacy "every visible project" behaviour.
|
||||
export async function initProjectTree(container: HTMLElement, params?: URLSearchParams) {
|
||||
mountContainer = container;
|
||||
mountParams = params ? new URLSearchParams(params) : new URLSearchParams();
|
||||
// Honour the orchestrator's subtree_counts param when the tree renders
|
||||
// its badges. Default true.
|
||||
const sc = mountParams.get("subtree_counts");
|
||||
useSubtreeCounts = sc === null ? true : sc === "true";
|
||||
// If params changed, the cache is stale.
|
||||
if (cache && cacheParams !== mountParams.toString()) {
|
||||
cache = null;
|
||||
}
|
||||
if (!cache) {
|
||||
container.innerHTML = `<div class="projekt-tree-loading">${esc(t("projects.tree.loading") || "Baum wird geladen…")}</div>`;
|
||||
const data = await fetchTree(container);
|
||||
const data = await fetchTree(container, mountParams);
|
||||
if (!data) return;
|
||||
}
|
||||
rerender();
|
||||
}
|
||||
|
||||
export function refreshProjectTree() {
|
||||
// refreshProjectTree forces a fresh fetch — used by the orchestrator
|
||||
// after a chip change or after a pin toggle invalidates the cache.
|
||||
export function refreshProjectTree(params?: URLSearchParams) {
|
||||
cache = null;
|
||||
cacheParams = "";
|
||||
if (mountContainer) {
|
||||
void initProjectTree(mountContainer);
|
||||
void initProjectTree(mountContainer, params || mountParams);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
796
frontend/src/client/projects-cards.ts
Normal file
796
frontend/src/client/projects-cards.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
|
||||
// /projects Cards view (t-paliad-149 PR 2).
|
||||
//
|
||||
// Renders one card per project with configurable facts (title row, type
|
||||
// chip, status, clientmatter, parent path, deadline counts, next 3 events,
|
||||
// last 3 Verlauf entries, team chips). Layout is per-user, named, and
|
||||
// drag-rearrangeable in edit mode (see editMode flag below).
|
||||
//
|
||||
// Data flow:
|
||||
// 1. orchestrator (client/projects.ts) calls renderCardsView(...)
|
||||
// 2. we fetch the active layout (GET /api/user-card-layouts → default first)
|
||||
// 3. we fetch the projects tree with the orchestrator's chip/search params
|
||||
// 4. we fetch /api/projects/cards-preview for per-project event rollups
|
||||
// 5. flatten tree to cards (leaf-ish only by default; "Alle Ebenen" toggle)
|
||||
// 6. render the grid; lazy-fill preview slots via IntersectionObserver
|
||||
// when the project list is huge (cap currently 200 — paliad fits today)
|
||||
//
|
||||
// Edit mode toggles in-place: each card grows drag handles + visibility
|
||||
// toggles + count steppers; the toolbar grows save/discard/rename/delete.
|
||||
|
||||
import type { ProjectTreeNode } from "./project-tree";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Types — mirror internal/services/layout_spec.go
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type FactKey =
|
||||
| "title-row"
|
||||
| "type-chip"
|
||||
| "status-chip"
|
||||
| "client-matter"
|
||||
| "parent-path"
|
||||
| "deadline-counts"
|
||||
| "next-events"
|
||||
| "recent-verlauf"
|
||||
| "team-chips"
|
||||
| "reference"
|
||||
| "last-activity-at";
|
||||
|
||||
const ALL_FACT_KEYS: FactKey[] = [
|
||||
"title-row",
|
||||
"type-chip",
|
||||
"status-chip",
|
||||
"client-matter",
|
||||
"parent-path",
|
||||
"deadline-counts",
|
||||
"next-events",
|
||||
"recent-verlauf",
|
||||
"team-chips",
|
||||
"reference",
|
||||
"last-activity-at",
|
||||
];
|
||||
|
||||
interface LayoutFact {
|
||||
key: FactKey;
|
||||
visible: boolean;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface LayoutSpec {
|
||||
facts: LayoutFact[];
|
||||
density: "compact" | "roomy";
|
||||
grid_columns: "auto" | "2" | "3" | "4";
|
||||
show_all_levels: boolean;
|
||||
}
|
||||
|
||||
interface UserCardLayout {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
layout: LayoutSpec;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CardEventPreview {
|
||||
kind: "deadline" | "appointment" | "project_event";
|
||||
id: string;
|
||||
title: string;
|
||||
event_date: string;
|
||||
status?: string | null;
|
||||
actor_name?: string | null;
|
||||
route: string;
|
||||
}
|
||||
|
||||
interface ProjectCardPreview {
|
||||
project_id: string;
|
||||
next_events: CardEventPreview[];
|
||||
recent_verlauf: CardEventPreview[];
|
||||
team_initials: string[];
|
||||
team_count: number;
|
||||
last_activity_at?: string | null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Module state
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
let layouts: UserCardLayout[] = [];
|
||||
let activeLayoutId: string | null = null;
|
||||
let editMode = false;
|
||||
let editDraft: LayoutSpec | null = null;
|
||||
let treeCache: ProjectTreeNode[] = [];
|
||||
let previewCache: Map<string, ProjectCardPreview> = new Map();
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Public entry — called by the orchestrator when view-mode = "cards"
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export interface CardsViewOpts {
|
||||
treeParams: URLSearchParams;
|
||||
}
|
||||
|
||||
export async function renderCardsView(opts: CardsViewOpts) {
|
||||
const wrap = document.getElementById("projects-cards-wrap");
|
||||
const toolbar = document.getElementById("projects-cards-toolbar");
|
||||
const grid = document.getElementById("projects-cards-grid");
|
||||
if (!wrap || !toolbar || !grid) return;
|
||||
wrap.style.display = "block";
|
||||
toolbar.style.display = "flex";
|
||||
|
||||
// Step 1: layouts.
|
||||
if (layouts.length === 0) {
|
||||
await reloadLayouts();
|
||||
}
|
||||
if (layouts.length === 0) {
|
||||
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
|
||||
return;
|
||||
}
|
||||
if (!activeLayoutId) {
|
||||
const def = layouts.find((l) => l.is_default) || layouts[0];
|
||||
activeLayoutId = def.id;
|
||||
}
|
||||
populateLayoutSelect();
|
||||
attachToolbarHandlers();
|
||||
|
||||
// Step 2: tree (chip/search-narrowed) + cards preview.
|
||||
const treeURL = `/api/projects/tree?${opts.treeParams.toString()}`;
|
||||
const previewURL = `/api/projects/cards-preview`;
|
||||
const [treeResp, previewResp] = await Promise.all([
|
||||
fetch(treeURL).then((r) => (r.ok ? r.json() : [])),
|
||||
fetch(previewURL).then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
treeCache = treeResp as ProjectTreeNode[];
|
||||
previewCache = new Map();
|
||||
for (const p of previewResp as ProjectCardPreview[]) {
|
||||
previewCache.set(p.project_id, p);
|
||||
}
|
||||
|
||||
rerender();
|
||||
}
|
||||
|
||||
export function teardownCardsView() {
|
||||
const wrap = document.getElementById("projects-cards-wrap");
|
||||
const toolbar = document.getElementById("projects-cards-toolbar");
|
||||
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
|
||||
if (wrap) wrap.style.display = "none";
|
||||
if (toolbar) toolbar.style.display = "none";
|
||||
if (editToolbar) editToolbar.style.display = "none";
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Layout management — fetch + select + edit mode
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
async function reloadLayouts(): Promise<void> {
|
||||
const resp = await fetch("/api/user-card-layouts");
|
||||
if (!resp.ok) {
|
||||
layouts = [];
|
||||
return;
|
||||
}
|
||||
layouts = (await resp.json()) as UserCardLayout[];
|
||||
// Server may not have any rows yet — auto-seed by hitting the cards
|
||||
// view's "default" path: GET /api/user-card-layouts/__seed-default__
|
||||
// Actually the seed happens server-side on GetDefault; we trigger it
|
||||
// by making a no-op preview request which doesn't pull layouts. The
|
||||
// simplest path: if empty, POST a Standard layout from the client.
|
||||
if (layouts.length === 0) {
|
||||
const seed = await fetch("/api/user-card-layouts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Standard", layout: defaultLayout(), is_default: true }),
|
||||
});
|
||||
if (seed.ok) {
|
||||
const row = (await seed.json()) as UserCardLayout;
|
||||
layouts = [row];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLayout(): LayoutSpec {
|
||||
return {
|
||||
facts: [
|
||||
{ key: "title-row", visible: true },
|
||||
{ key: "type-chip", visible: true },
|
||||
{ key: "status-chip", visible: true },
|
||||
{ key: "client-matter", visible: true },
|
||||
{ key: "parent-path", visible: true },
|
||||
{ key: "deadline-counts", visible: true },
|
||||
{ key: "next-events", visible: true, count: 3 },
|
||||
{ key: "recent-verlauf", visible: true, count: 3 },
|
||||
{ key: "team-chips", visible: true },
|
||||
],
|
||||
density: "roomy",
|
||||
grid_columns: "auto",
|
||||
show_all_levels: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveLayout(): UserCardLayout | null {
|
||||
if (!activeLayoutId) return null;
|
||||
return layouts.find((l) => l.id === activeLayoutId) || null;
|
||||
}
|
||||
|
||||
function getEffectiveSpec(): LayoutSpec {
|
||||
if (editMode && editDraft) return editDraft;
|
||||
const a = getActiveLayout();
|
||||
return a ? a.layout : defaultLayout();
|
||||
}
|
||||
|
||||
function populateLayoutSelect() {
|
||||
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
for (const l of layouts) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = l.id;
|
||||
opt.textContent = l.is_default ? `${l.name} · ${t("projects.cards.layout.is_default") || "Standard"}` : l.name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (activeLayoutId) sel.value = activeLayoutId;
|
||||
}
|
||||
|
||||
function attachToolbarHandlers() {
|
||||
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
|
||||
const editBtn = document.getElementById("projects-cards-layout-edit") as HTMLButtonElement | null;
|
||||
const newBtn = document.getElementById("projects-cards-layout-new") as HTMLButtonElement | null;
|
||||
const showAll = document.getElementById("projects-cards-show-all-levels") as HTMLInputElement | null;
|
||||
|
||||
// Idempotent attach guards via dataset flag.
|
||||
if (sel && !sel.dataset.bound) {
|
||||
sel.dataset.bound = "1";
|
||||
sel.addEventListener("change", () => {
|
||||
activeLayoutId = sel.value;
|
||||
const a = getActiveLayout();
|
||||
if (showAll && a) showAll.checked = a.layout.show_all_levels;
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
if (editBtn && !editBtn.dataset.bound) {
|
||||
editBtn.dataset.bound = "1";
|
||||
editBtn.addEventListener("click", () => enterEditMode());
|
||||
}
|
||||
if (newBtn && !newBtn.dataset.bound) {
|
||||
newBtn.dataset.bound = "1";
|
||||
newBtn.addEventListener("click", () => createNewLayout());
|
||||
}
|
||||
if (showAll && !showAll.dataset.bound) {
|
||||
showAll.dataset.bound = "1";
|
||||
showAll.checked = getEffectiveSpec().show_all_levels;
|
||||
showAll.addEventListener("change", () => {
|
||||
// In view mode this is a save-as-active toggle. In edit mode it
|
||||
// updates the draft.
|
||||
if (editMode && editDraft) {
|
||||
editDraft.show_all_levels = showAll.checked;
|
||||
rerender();
|
||||
} else {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
const newSpec: LayoutSpec = { ...a.layout, show_all_levels: showAll.checked };
|
||||
void persistLayout(a.id, { layout: newSpec });
|
||||
a.layout = newSpec;
|
||||
rerender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Edit-toolbar wiring (only first time the elements exist).
|
||||
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
|
||||
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
|
||||
const eRename = document.getElementById("projects-cards-edit-rename") as HTMLButtonElement | null;
|
||||
const eDelete = document.getElementById("projects-cards-edit-delete") as HTMLButtonElement | null;
|
||||
const eSetDefault = document.getElementById("projects-cards-edit-set-default") as HTMLButtonElement | null;
|
||||
const eDiscard = document.getElementById("projects-cards-edit-discard") as HTMLButtonElement | null;
|
||||
const eSave = document.getElementById("projects-cards-edit-save") as HTMLButtonElement | null;
|
||||
|
||||
if (eDensity && !eDensity.dataset.bound) {
|
||||
eDensity.dataset.bound = "1";
|
||||
eDensity.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
editDraft.density = eDensity.value as "compact" | "roomy";
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
if (eGrid && !eGrid.dataset.bound) {
|
||||
eGrid.dataset.bound = "1";
|
||||
eGrid.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
editDraft.grid_columns = eGrid.value as LayoutSpec["grid_columns"];
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
if (eRename && !eRename.dataset.bound) {
|
||||
eRename.dataset.bound = "1";
|
||||
eRename.addEventListener("click", () => renameActiveLayout());
|
||||
}
|
||||
if (eDelete && !eDelete.dataset.bound) {
|
||||
eDelete.dataset.bound = "1";
|
||||
eDelete.addEventListener("click", () => deleteActiveLayout());
|
||||
}
|
||||
if (eSetDefault && !eSetDefault.dataset.bound) {
|
||||
eSetDefault.dataset.bound = "1";
|
||||
eSetDefault.addEventListener("click", () => setActiveAsDefault());
|
||||
}
|
||||
if (eDiscard && !eDiscard.dataset.bound) {
|
||||
eDiscard.dataset.bound = "1";
|
||||
eDiscard.addEventListener("click", () => leaveEditMode(false));
|
||||
}
|
||||
if (eSave && !eSave.dataset.bound) {
|
||||
eSave.dataset.bound = "1";
|
||||
eSave.addEventListener("click", () => leaveEditMode(true));
|
||||
}
|
||||
}
|
||||
|
||||
function enterEditMode() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
editMode = true;
|
||||
editDraft = JSON.parse(JSON.stringify(a.layout)) as LayoutSpec;
|
||||
reflectEditToolbar();
|
||||
rerender();
|
||||
}
|
||||
|
||||
async function leaveEditMode(saveChanges: boolean) {
|
||||
const a = getActiveLayout();
|
||||
if (!a) {
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
rerender();
|
||||
return;
|
||||
}
|
||||
if (saveChanges && editDraft) {
|
||||
await persistLayout(a.id, { layout: editDraft });
|
||||
a.layout = editDraft;
|
||||
}
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
reflectEditToolbar();
|
||||
rerender();
|
||||
}
|
||||
|
||||
function reflectEditToolbar() {
|
||||
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
|
||||
if (editToolbar) editToolbar.style.display = editMode ? "flex" : "none";
|
||||
|
||||
if (editMode && editDraft) {
|
||||
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
|
||||
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
|
||||
if (eDensity) eDensity.value = editDraft.density;
|
||||
if (eGrid) eGrid.value = editDraft.grid_columns;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistLayout(id: string, patch: Partial<{ name: string; layout: LayoutSpec; is_default: boolean }>) {
|
||||
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const row = (await resp.json()) as UserCardLayout;
|
||||
const idx = layouts.findIndex((l) => l.id === id);
|
||||
if (idx >= 0) layouts[idx] = row;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewLayout() {
|
||||
const name = window.prompt(t("projects.cards.layout.new.prompt") || "Name der neuen Ansicht");
|
||||
if (!name || !name.trim()) return;
|
||||
const seed = JSON.parse(JSON.stringify(getEffectiveSpec())) as LayoutSpec;
|
||||
const resp = await fetch("/api/user-card-layouts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), layout: seed, is_default: false }),
|
||||
});
|
||||
if (resp.status === 409) {
|
||||
window.alert("Name already exists.");
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
const row = (await resp.json()) as UserCardLayout;
|
||||
layouts.push(row);
|
||||
activeLayoutId = row.id;
|
||||
populateLayoutSelect();
|
||||
enterEditMode();
|
||||
}
|
||||
|
||||
async function renameActiveLayout() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
const name = window.prompt(t("projects.cards.layout.rename") || "Umbenennen", a.name);
|
||||
if (!name || !name.trim() || name.trim() === a.name) return;
|
||||
await persistLayout(a.id, { name: name.trim() });
|
||||
populateLayoutSelect();
|
||||
}
|
||||
|
||||
async function deleteActiveLayout() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
if (a.is_default) {
|
||||
window.alert(t("projects.cards.layout.delete.default_blocked") || "Cannot delete default.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t("projects.cards.layout.delete.confirm") || "Delete?")) return;
|
||||
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) return;
|
||||
layouts = layouts.filter((l) => l.id !== a.id);
|
||||
const def = layouts.find((l) => l.is_default) || layouts[0];
|
||||
activeLayoutId = def ? def.id : null;
|
||||
editMode = false;
|
||||
editDraft = null;
|
||||
populateLayoutSelect();
|
||||
reflectEditToolbar();
|
||||
rerender();
|
||||
}
|
||||
|
||||
async function setActiveAsDefault() {
|
||||
const a = getActiveLayout();
|
||||
if (!a) return;
|
||||
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}/set-default`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
// Refresh the list — server cleared the prior default in tx.
|
||||
await reloadLayouts();
|
||||
populateLayoutSelect();
|
||||
rerender();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function rerender() {
|
||||
const grid = document.getElementById("projects-cards-grid");
|
||||
if (!grid) return;
|
||||
const spec = getEffectiveSpec();
|
||||
|
||||
// Apply grid columns + density.
|
||||
grid.classList.toggle("is-density-compact", spec.density === "compact");
|
||||
grid.classList.toggle("is-density-roomy", spec.density === "roomy");
|
||||
grid.classList.remove("is-grid-2", "is-grid-3", "is-grid-4");
|
||||
if (spec.grid_columns !== "auto") {
|
||||
grid.classList.add(`is-grid-${spec.grid_columns}`);
|
||||
}
|
||||
|
||||
const cards = flattenTreeToCards(treeCache, spec.show_all_levels);
|
||||
if (cards.length === 0) {
|
||||
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = cards.map((n) => renderCard(n, spec)).join("");
|
||||
attachCardHandlers(grid);
|
||||
}
|
||||
|
||||
function flattenTreeToCards(roots: ProjectTreeNode[], showAllLevels: boolean): ProjectTreeNode[] {
|
||||
const out: ProjectTreeNode[] = [];
|
||||
const walk = (n: ProjectTreeNode) => {
|
||||
if (showAllLevels) {
|
||||
out.push(n);
|
||||
} else if (isLeafish(n)) {
|
||||
out.push(n);
|
||||
}
|
||||
for (const c of n.children) walk(c);
|
||||
};
|
||||
roots.forEach(walk);
|
||||
// Sort by last_activity_at DESC (from preview), pinned first.
|
||||
out.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
const aT = previewCache.get(a.id)?.last_activity_at || "";
|
||||
const bT = previewCache.get(b.id)?.last_activity_at || "";
|
||||
if (aT !== bT) return bT.localeCompare(aT);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLeafish(n: ProjectTreeNode): boolean {
|
||||
// Cases / Patents / Verfahren / Projekte. Mandanten + Litigations are
|
||||
// scaffolding when "Alle Ebenen" is off.
|
||||
return n.type === "case" || n.type === "patent" || n.type === "project";
|
||||
}
|
||||
|
||||
function renderCard(n: ProjectTreeNode, spec: LayoutSpec): string {
|
||||
const visibleFacts = spec.facts.filter((f) => f.visible);
|
||||
const factHTML = visibleFacts.map((f) => renderFact(n, f, spec)).join("");
|
||||
|
||||
const editChrome = editMode ? renderEditChromeForCard(spec) : "";
|
||||
|
||||
return (
|
||||
`<article class="projects-card${editMode ? " is-edit-mode" : ""}" data-id="${escAttr(n.id)}">` +
|
||||
factHTML +
|
||||
editChrome +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditChromeForCard(spec: LayoutSpec): string {
|
||||
// Inline fact-row controls (drag handle + visibility toggle) per fact key.
|
||||
// We render this as a horizontally-laid-out list at the bottom of each
|
||||
// card so the user can rearrange order. In edit mode the rendered facts
|
||||
// above are "preview"; the ordering controls below mutate the draft.
|
||||
const rows = spec.facts.map((f, i) => {
|
||||
const label = t(`projects.cards.layout.fact.${f.key}` as never) || f.key;
|
||||
const cnt = f.count !== undefined
|
||||
? `<input type="number" min="1" max="5" value="${f.count}" class="projects-cards-edit-count" data-key="${escAttr(f.key)}" />`
|
||||
: "";
|
||||
return (
|
||||
`<li class="projects-cards-edit-fact" draggable="true" data-key="${escAttr(f.key)}" data-index="${i}">` +
|
||||
`<span class="projects-cards-edit-handle" aria-hidden="true">⠿</span>` +
|
||||
`<input type="checkbox" class="projects-cards-edit-vis" ${f.visible ? "checked" : ""} data-key="${escAttr(f.key)}" />` +
|
||||
`<span class="projects-cards-edit-label">${escHTML(String(label))}</span>` +
|
||||
cnt +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
return `<ul class="projects-cards-edit-facts">${rows}</ul>`;
|
||||
}
|
||||
|
||||
function renderFact(n: ProjectTreeNode, f: LayoutFact, spec: LayoutSpec): string {
|
||||
const preview = previewCache.get(n.id);
|
||||
switch (f.key) {
|
||||
case "title-row":
|
||||
return renderTitleRow(n);
|
||||
case "type-chip":
|
||||
return `<div class="projects-card-row"><span class="entity-type-chip entity-type-${escAttr(n.type)}">${escHTML(String(tDyn(`projects.type.${n.type}`) || n.type))}</span></div>`;
|
||||
case "status-chip":
|
||||
return `<div class="projects-card-row"><span class="entity-status-chip entity-status-${escAttr(n.status)}">${escHTML(String(tDyn(`projects.filter.status.${n.status}`) || n.status))}</span></div>`;
|
||||
case "client-matter": {
|
||||
const cm = (n.client_number && n.matter_number)
|
||||
? `${n.client_number}.${n.matter_number}`
|
||||
: (n.client_number || n.matter_number || "");
|
||||
if (!cm) return "";
|
||||
return `<div class="projects-card-row projects-card-cm">${escHTML(cm)}</div>`;
|
||||
}
|
||||
case "parent-path":
|
||||
// Parent path is omitted in v1 — building the breadcrumb requires
|
||||
// an extra fetch per card. Display the project's own .reference if
|
||||
// present as a stand-in cue for hierarchy.
|
||||
if (!n.reference) return "";
|
||||
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
|
||||
case "deadline-counts": {
|
||||
const open = n.open_deadlines_subtree ?? n.open_deadlines;
|
||||
const overdue = n.overdue_deadlines_subtree ?? n.overdue_deadlines;
|
||||
if (open === 0 && overdue === 0) return "";
|
||||
const parts: string[] = [];
|
||||
if (overdue > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-overdue">${overdue} ${escHTML(String(t("projects.cards.deadline_overdue") || "überfällig"))}</span>`);
|
||||
if (open > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-open">${open} ${escHTML(String(t("projects.cards.deadline_open") || "offen"))}</span>`);
|
||||
return `<div class="projects-card-row projects-card-counts">${parts.join(" ")}</div>`;
|
||||
}
|
||||
case "next-events": {
|
||||
const cap = clampCount(f.count);
|
||||
const evs = preview?.next_events || [];
|
||||
if (evs.length === 0) {
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_next_events") || ""))}</div></div>`;
|
||||
}
|
||||
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div>${rows}</div>`;
|
||||
}
|
||||
case "recent-verlauf": {
|
||||
const cap = clampCount(f.count);
|
||||
const evs = preview?.recent_verlauf || [];
|
||||
if (evs.length === 0) {
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_recent") || ""))}</div></div>`;
|
||||
}
|
||||
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
|
||||
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div>${rows}</div>`;
|
||||
}
|
||||
case "team-chips": {
|
||||
if (!preview || preview.team_count === 0) return "";
|
||||
const initials = preview.team_initials.map((i) => `<span class="projects-card-team-initial">${escHTML(i)}</span>`).join("");
|
||||
const overflow = preview.team_count > preview.team_initials.length
|
||||
? `<span class="projects-card-team-overflow">+${preview.team_count - preview.team_initials.length}</span>`
|
||||
: "";
|
||||
return `<div class="projects-card-row projects-card-team">${initials}${overflow}</div>`;
|
||||
}
|
||||
case "reference":
|
||||
if (!n.reference) return "";
|
||||
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
|
||||
case "last-activity-at": {
|
||||
const at = preview?.last_activity_at;
|
||||
if (!at) return "";
|
||||
return `<div class="projects-card-row projects-card-last-activity">${escHTML(fmtDate(at))}</div>`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
// unreachable
|
||||
void spec;
|
||||
}
|
||||
|
||||
function renderTitleRow(n: ProjectTreeNode): string {
|
||||
const pinClass = n.pinned ? " is-pinned" : "";
|
||||
const pinLabel = n.pinned
|
||||
? (t("projects.tree.unpin") || "Pin entfernen")
|
||||
: (t("projects.tree.pin") || "Anpinnen");
|
||||
return (
|
||||
`<div class="projects-card-title-row">` +
|
||||
`<span class="projects-card-icon projekt-tree-icon-${escAttr(n.type)}" aria-hidden="true">●</span>` +
|
||||
`<a class="projects-card-title" href="/projects/${escAttr(n.id)}">${escHTML(n.title)}</a>` +
|
||||
`<button type="button" class="projekt-tree-pin projects-card-pin${pinClass}" data-id="${escAttr(n.id)}" aria-label="${escAttr(String(pinLabel))}" title="${escAttr(String(pinLabel))}">${n.pinned ? "★" : "☆"}</button>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderEventRow(e: CardEventPreview): string {
|
||||
const kindLabel = t(`projects.cards.event.kind.${e.kind}` as never) || e.kind;
|
||||
const dateStr = fmtDate(e.event_date);
|
||||
const status = e.status ? ` <span class="projects-card-event-status entity-status-chip entity-status-${escAttr(e.status)}">${escHTML(String(tDyn(`projects.filter.status.${e.status}`) || e.status))}</span>` : "";
|
||||
const actor = e.actor_name ? ` <span class="projects-card-event-actor">${escHTML(e.actor_name)}</span>` : "";
|
||||
return (
|
||||
`<a class="projects-card-event-row" href="${escAttr(e.route)}" title="${escAttr(e.title)}">` +
|
||||
`<span class="projects-card-event-date">${escHTML(dateStr)}</span>` +
|
||||
`<span class="projects-card-event-kind">${escHTML(String(kindLabel))}</span>` +
|
||||
`<span class="projects-card-event-title">${escHTML(e.title)}</span>` +
|
||||
status +
|
||||
actor +
|
||||
`</a>`
|
||||
);
|
||||
}
|
||||
|
||||
function clampCount(n: number | undefined): number {
|
||||
if (n === undefined) return 3;
|
||||
if (n < 1) return 1;
|
||||
if (n > 5) return 5;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Card-level event handlers (pin click + edit-mode drag/check/count)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function attachCardHandlers(grid: HTMLElement) {
|
||||
// Pin star (always-active, edit mode or not).
|
||||
grid.querySelectorAll<HTMLButtonElement>(".projects-card-pin").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.id!;
|
||||
void togglePin(id, btn);
|
||||
});
|
||||
});
|
||||
|
||||
if (!editMode || !editDraft) return;
|
||||
|
||||
// Edit-mode: visibility checkbox per fact.
|
||||
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-vis").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
const key = cb.dataset.key as FactKey;
|
||||
const f = editDraft.facts.find((x) => x.key === key);
|
||||
if (!f) return;
|
||||
f.visible = cb.checked;
|
||||
// The first VISIBLE fact must remain "title-row" — gate this.
|
||||
// Easier approach: always keep title-row visible regardless of click.
|
||||
const titleRow = editDraft.facts.find((x) => x.key === "title-row");
|
||||
if (titleRow) titleRow.visible = true;
|
||||
rerender();
|
||||
});
|
||||
});
|
||||
|
||||
// Edit-mode: count steppers for next-events / recent-verlauf.
|
||||
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-count").forEach((inp) => {
|
||||
inp.addEventListener("change", () => {
|
||||
if (!editDraft) return;
|
||||
const key = inp.dataset.key as FactKey;
|
||||
const f = editDraft.facts.find((x) => x.key === key);
|
||||
if (!f) return;
|
||||
const v = Math.max(1, Math.min(5, parseInt(inp.value, 10) || 3));
|
||||
f.count = v;
|
||||
rerender();
|
||||
});
|
||||
});
|
||||
|
||||
// Edit-mode: HTML5 drag-and-drop for fact reordering. We attach handlers
|
||||
// on each .projects-cards-edit-fact <li>; dragover on <ul>; drop reorders
|
||||
// the editDraft.facts array.
|
||||
grid.querySelectorAll<HTMLLIElement>(".projects-cards-edit-fact").forEach((li) => {
|
||||
li.addEventListener("dragstart", (ev) => {
|
||||
const key = li.dataset.key!;
|
||||
ev.dataTransfer?.setData("text/plain", key);
|
||||
li.classList.add("is-dragging");
|
||||
});
|
||||
li.addEventListener("dragend", () => li.classList.remove("is-dragging"));
|
||||
li.addEventListener("dragover", (ev) => {
|
||||
ev.preventDefault();
|
||||
li.classList.add("is-drop-target");
|
||||
});
|
||||
li.addEventListener("dragleave", () => li.classList.remove("is-drop-target"));
|
||||
li.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
li.classList.remove("is-drop-target");
|
||||
const fromKey = ev.dataTransfer?.getData("text/plain") as FactKey | undefined;
|
||||
const toKey = li.dataset.key as FactKey;
|
||||
if (!fromKey || !toKey || fromKey === toKey) return;
|
||||
reorderFacts(fromKey, toKey);
|
||||
rerender();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reorderFacts(fromKey: FactKey, toKey: FactKey) {
|
||||
if (!editDraft) return;
|
||||
const fromIdx = editDraft.facts.findIndex((f) => f.key === fromKey);
|
||||
const toIdx = editDraft.facts.findIndex((f) => f.key === toKey);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const [moved] = editDraft.facts.splice(fromIdx, 1);
|
||||
editDraft.facts.splice(toIdx, 0, moved);
|
||||
// Server validator requires title-row to be the first visible fact.
|
||||
// Pull it to the top if it's now somewhere else.
|
||||
const trIdx = editDraft.facts.findIndex((f) => f.key === "title-row");
|
||||
if (trIdx > 0) {
|
||||
const [tr] = editDraft.facts.splice(trIdx, 1);
|
||||
editDraft.facts.unshift(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePin(projectID: string, btn: HTMLElement) {
|
||||
const wasPinned = btn.classList.contains("is-pinned");
|
||||
btn.classList.toggle("is-pinned", !wasPinned);
|
||||
btn.textContent = !wasPinned ? "★" : "☆";
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/pin`, {
|
||||
method: wasPinned ? "DELETE" : "POST",
|
||||
});
|
||||
if (!resp.ok && resp.status !== 201 && resp.status !== 204) {
|
||||
btn.classList.toggle("is-pinned", wasPinned);
|
||||
btn.textContent = wasPinned ? "★" : "☆";
|
||||
return;
|
||||
}
|
||||
// Update tree cache in place so re-renders show the new state.
|
||||
const update = (n: ProjectTreeNode) => {
|
||||
if (n.id === projectID) n.pinned = !wasPinned;
|
||||
n.children.forEach(update);
|
||||
};
|
||||
treeCache.forEach(update);
|
||||
} catch {
|
||||
btn.classList.toggle("is-pinned", wasPinned);
|
||||
btn.textContent = wasPinned ? "★" : "☆";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function escHTML(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => {
|
||||
switch (c) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
case "'": return "'";
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
|
||||
// Avoid an "unused" warning from the type-only import.
|
||||
const _unusedFactKeys: FactKey[] = ALL_FACT_KEYS;
|
||||
void _unusedFactKeys;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree";
|
||||
import {
|
||||
loadParentCandidates,
|
||||
initParentPicker,
|
||||
@@ -37,15 +38,52 @@ interface ProjectTeamMember {
|
||||
id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
// t-paliad-148: per-project responsibility (lead/member/observer/external).
|
||||
// The legacy .role field is still set by the server during the
|
||||
// deprecation window but the UI ignores it for new code.
|
||||
responsibility: string;
|
||||
role: string;
|
||||
inherited: boolean;
|
||||
user_email: string;
|
||||
user_display_name: string;
|
||||
user_office: string;
|
||||
// user_profession is the structured firm tier (partner/of_counsel/…/
|
||||
// paralegal). NULL means external collaborator. Read-only here — the
|
||||
// value is set on the user's firm profile, not at staffing time.
|
||||
user_profession?: string | null;
|
||||
inherited_from_id?: string | null;
|
||||
inherited_from_title?: string | null;
|
||||
}
|
||||
|
||||
// t-paliad-139 — derived team member from a partner-unit attachment.
|
||||
// One DerivedMember per user; users in multiple attached units carry one
|
||||
// DerivedMembership per (unit, role) pair so the Herkunft column can list
|
||||
// every source (t-paliad-143).
|
||||
interface DerivedMembership {
|
||||
unit_id: string;
|
||||
unit_name: string;
|
||||
unit_role: string;
|
||||
}
|
||||
|
||||
interface DerivedMember {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_display_name: string;
|
||||
user_office: string;
|
||||
memberships: DerivedMembership[];
|
||||
derive_grants_authority: boolean;
|
||||
}
|
||||
|
||||
// t-paliad-139 — partner unit attached to this project.
|
||||
interface AttachedUnit {
|
||||
project_id: string;
|
||||
partner_unit_id: string;
|
||||
unit_name: string;
|
||||
derive_unit_roles: string[];
|
||||
derive_grants_authority: boolean;
|
||||
derived_member_count: number;
|
||||
}
|
||||
|
||||
interface ProjectMini {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -71,6 +109,10 @@ interface ProjectEvent {
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
// Populated only when the response was joined to paliad.projects (Verlauf
|
||||
// subtree-aggregating queries on /projects/{id}, t-paliad-139). Used to
|
||||
// render the attribution chip when the event lives on a descendant.
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
interface Deadline {
|
||||
@@ -81,6 +123,10 @@ interface Deadline {
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
// Populated by the union endpoint (/api/events) which is what the project
|
||||
// detail page calls — used for attribution when the row lives on a
|
||||
// descendant project (t-paliad-139).
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
@@ -91,6 +137,7 @@ interface Appointment {
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -160,13 +207,50 @@ let deadlines: Deadline[] = [];
|
||||
let appointments: Appointment[] = [];
|
||||
let ancestors: ProjectMini[] = [];
|
||||
let children: ProjectMini[] = [];
|
||||
// projects-cards' /projects tree owns rendering inside the Projektbaum
|
||||
// tab too — see initProjectTreeTab below. Local children[] still feeds
|
||||
// the empty-state gate + the create-link parent_id pre-fill.
|
||||
let teamMembers: ProjectTeamMember[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string }[] = [];
|
||||
// t-paliad-139 — additional Team-tab sections.
|
||||
let descendantStaffed: ProjectTeamMember[] = [];
|
||||
let derivedMembers: DerivedMember[] = [];
|
||||
let attachedUnits: AttachedUnit[] = [];
|
||||
let allUnits: { id: string; name: string; office: string }[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
|
||||
|
||||
const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
|
||||
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
|
||||
// Verlauf show rows from this project AND all descendant projects with an
|
||||
// attribution chip per non-direct row. URL param `?subtree=false` flips to
|
||||
// narrow (this project's own rows only).
|
||||
let subtreeMode: boolean = true;
|
||||
|
||||
function parseSubtreeMode(): boolean {
|
||||
try {
|
||||
const raw = new URLSearchParams(window.location.search).get("subtree");
|
||||
return raw !== "false";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSubtreeMode() {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
if (subtreeMode) {
|
||||
url.searchParams.delete("subtree");
|
||||
} else {
|
||||
url.searchParams.set("subtree", "false");
|
||||
}
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function parseProjectID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "projects" || !parts[1]) return null;
|
||||
@@ -211,9 +295,18 @@ async function loadParties(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a query string suffix conveying the current subtree mode. The
|
||||
// backend defaults to subtree (direct_only=false), so we only emit the
|
||||
// param when the user has flipped to direct.
|
||||
function subtreeParam(): string {
|
||||
return subtreeMode ? "" : "&direct_only=true";
|
||||
}
|
||||
|
||||
async function loadEvents(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}`);
|
||||
const resp = await fetch(
|
||||
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
events = (await resp.json()) ?? [];
|
||||
eventsHasMore = events.length === EVENTS_PAGE_SIZE;
|
||||
@@ -238,7 +331,7 @@ async function loadMoreEvents(id: string) {
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}`,
|
||||
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
const page: ProjectEvent[] = await resp.json();
|
||||
@@ -257,10 +350,50 @@ async function loadMoreEvents(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Shape returned by /api/events — matches EventListItem in
|
||||
// frontend/src/client/events.ts. Only the fields projects-detail needs.
|
||||
interface UnionEvent {
|
||||
type: "deadline" | "appointment";
|
||||
id: string;
|
||||
title: string;
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
due_date?: string;
|
||||
status?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
}
|
||||
|
||||
async function loadDeadlines(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/deadlines`);
|
||||
if (resp.ok) deadlines = (await resp.json()) ?? [];
|
||||
// t-paliad-139: switched from /api/projects/{id}/deadlines (legacy
|
||||
// narrow path) to the union endpoint, which already aggregates
|
||||
// descendants and enriches each row with project_title for the
|
||||
// attribution chip.
|
||||
const resp = await fetch(
|
||||
`/api/events?type=deadline&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
const items: UnionEvent[] = (await resp.json()) ?? [];
|
||||
deadlines = items
|
||||
.filter((it) => it.type === "deadline")
|
||||
.map((it) => ({
|
||||
id: it.id,
|
||||
project_id: it.project_id ?? "",
|
||||
title: it.title,
|
||||
due_date: it.due_date ?? "",
|
||||
status: it.status ?? "pending",
|
||||
rule_id: it.rule_id,
|
||||
rule_code: it.rule_code,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
deadlines = [];
|
||||
}
|
||||
} catch {
|
||||
deadlines = [];
|
||||
}
|
||||
@@ -268,8 +401,27 @@ async function loadDeadlines(id: string) {
|
||||
|
||||
async function loadAppointments(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/appointments`);
|
||||
if (resp.ok) appointments = (await resp.json()) ?? [];
|
||||
// t-paliad-139: same migration as loadDeadlines.
|
||||
const resp = await fetch(
|
||||
`/api/events?type=appointment&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
const items: UnionEvent[] = (await resp.json()) ?? [];
|
||||
appointments = items
|
||||
.filter((it) => it.type === "appointment")
|
||||
.map((it) => ({
|
||||
id: it.id,
|
||||
project_id: it.project_id,
|
||||
title: it.title,
|
||||
start_at: it.start_at ?? "",
|
||||
end_at: it.end_at,
|
||||
location: it.location,
|
||||
appointment_type: it.appointment_type,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
appointments = [];
|
||||
}
|
||||
} catch {
|
||||
appointments = [];
|
||||
}
|
||||
@@ -310,7 +462,7 @@ function renderAppointments() {
|
||||
return `<tr class="termin-row" data-id="${esc(tt.id)}">
|
||||
<td class="frist-col-check"><span class="termin-dot ${typeClass}" /></td>
|
||||
<td>${esc(fmtDateTimeLocal(tt.start_at))}</td>
|
||||
<td>${esc(tt.title)}</td>
|
||||
<td>${esc(tt.title)}${attributionChip(tt.project_id, tt.project_title)}</td>
|
||||
<td>${esc(tt.location ?? "")}</td>
|
||||
<td><span class="termin-type-chip ${typeClass}">${esc(typeLabel)}</span></td>
|
||||
</tr>`;
|
||||
@@ -443,7 +595,7 @@ function renderDeadlines() {
|
||||
aria-label="${esc(t("deadlines.complete.action"))}" />
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
@@ -477,6 +629,19 @@ function renderDeadlines() {
|
||||
});
|
||||
}
|
||||
|
||||
// attributionChip renders a small inline chip showing which descendant
|
||||
// project a row actually anchors on, when the row is from an aggregated
|
||||
// subtree result and not from the project being viewed (t-paliad-139).
|
||||
// Returns "" when the row's project is the current page or attribution
|
||||
// data is missing.
|
||||
function attributionChip(rowProjectID?: string, rowProjectTitle?: string): string {
|
||||
if (!project) return "";
|
||||
if (!rowProjectID || !rowProjectTitle) return "";
|
||||
if (rowProjectID === project.id) return "";
|
||||
const label = t("aggregation.attribution.on") || "auf";
|
||||
return ` <span class="aggregation-chip" title="${escAttr(rowProjectTitle)}">${esc(label)}: ${esc(rowProjectTitle)}</span>`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
@@ -580,7 +745,7 @@ function renderEvents() {
|
||||
return `<li class="entity-event">
|
||||
<div class="entity-event-date">${fmtDateTime(e.created_at)}</div>
|
||||
<div class="entity-event-body">
|
||||
<div class="entity-event-title">${titleHTML}</div>
|
||||
<div class="entity-event-title">${titleHTML}${attributionChip(e.project_id, e.project_title)}</div>
|
||||
${description ? `<div class="entity-event-desc">${esc(description)}</div>` : ""}
|
||||
</div>
|
||||
</li>`;
|
||||
@@ -1117,6 +1282,10 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read subtree mode from URL once at startup; subsequent toggles update
|
||||
// the URL via persistSubtreeMode (replaceState — back-button friendly).
|
||||
subtreeMode = parseSubtreeMode();
|
||||
|
||||
await loadMe();
|
||||
const ok = await loadProject(id);
|
||||
if (!ok || !project) {
|
||||
@@ -1133,6 +1302,10 @@ async function main() {
|
||||
loadAncestors(id),
|
||||
loadChildren(id),
|
||||
loadTeam(id),
|
||||
loadDescendantStaffed(id),
|
||||
loadDerivedMembers(id),
|
||||
loadAttachedUnits(id),
|
||||
loadAllUnits(),
|
||||
loadUserList(),
|
||||
]);
|
||||
|
||||
@@ -1155,10 +1328,117 @@ async function main() {
|
||||
initTeamForm(id);
|
||||
initDelete();
|
||||
initEventsLoadMore();
|
||||
initSubtreeToggles(id);
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
|
||||
// tab (project lead / global_admin only). The select is populated from
|
||||
// /api/partner-units excluding units already attached.
|
||||
function initAttachUnitForm(id: string) {
|
||||
const wrap = document.getElementById("unit-attach-form-wrap");
|
||||
const form = document.getElementById("unit-attach-form") as HTMLFormElement | null;
|
||||
const showBtn = document.getElementById("unit-attach-show") as HTMLButtonElement | null;
|
||||
const cancelBtn = document.getElementById("unit-attach-cancel") as HTMLButtonElement | null;
|
||||
const select = document.getElementById("unit-attach-select") as HTMLSelectElement | null;
|
||||
if (!wrap || !form || !showBtn || !cancelBtn || !select) return;
|
||||
|
||||
if (!canManagePartnerUnits()) {
|
||||
showBtn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshSelect = () => {
|
||||
const attachedIDs = new Set(attachedUnits.map((u) => u.partner_unit_id));
|
||||
const placeholder = `<option value="">${esc(t("projects.team.units.choose") || "Bitte Unit wählen…")}</option>`;
|
||||
const opts = allUnits
|
||||
.filter((u) => !attachedIDs.has(u.id))
|
||||
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
|
||||
.join("");
|
||||
select.innerHTML = placeholder + opts;
|
||||
};
|
||||
refreshSelect();
|
||||
|
||||
showBtn.addEventListener("click", () => {
|
||||
refreshSelect();
|
||||
wrap.style.display = "";
|
||||
showBtn.style.display = "none";
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
form.reset();
|
||||
wrap.style.display = "none";
|
||||
showBtn.style.display = "";
|
||||
});
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const unitID = select.value;
|
||||
if (!unitID) return;
|
||||
const rolePA = (document.getElementById("unit-attach-role-pa") as HTMLInputElement).checked;
|
||||
const roleSenior = (document.getElementById("unit-attach-role-senior_pa") as HTMLInputElement).checked;
|
||||
const roleAtty = (document.getElementById("unit-attach-role-attorney") as HTMLInputElement).checked;
|
||||
const grantsAuthority = (document.getElementById("unit-attach-authority") as HTMLInputElement).checked;
|
||||
const roles: string[] = [];
|
||||
if (rolePA) roles.push("pa");
|
||||
if (roleSenior) roles.push("senior_pa");
|
||||
if (roleAtty) roles.push("attorney");
|
||||
if (roles.length === 0) {
|
||||
// Defaults: pa + senior_pa.
|
||||
roles.push("pa", "senior_pa");
|
||||
}
|
||||
const resp = await fetch(`/api/projects/${id}/partner-units`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
partner_unit_id: unitID,
|
||||
derive_unit_roles: roles,
|
||||
derive_grants_authority: grantsAuthority,
|
||||
}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
form.reset();
|
||||
wrap.style.display = "none";
|
||||
showBtn.style.display = "";
|
||||
await Promise.all([loadAttachedUnits(id), loadDerivedMembers(id)]);
|
||||
renderTeam();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// initSubtreeToggles wires the "Inkl. Unterprojekte / Nur direkt" buttons
|
||||
// in the History, Deadlines, and Appointments sections. State is shared
|
||||
// across the three sections (one toggle flips all) and persisted in the
|
||||
// URL via ?subtree=false. Default = subtree (true).
|
||||
function initSubtreeToggles(id: string) {
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>(".subtree-toggle");
|
||||
if (buttons.length === 0) return;
|
||||
|
||||
const refreshLabels = () => {
|
||||
buttons.forEach((btn) => {
|
||||
btn.textContent = subtreeMode
|
||||
? t("aggregation.toggle.subtree")
|
||||
: t("aggregation.toggle.direct_only");
|
||||
btn.setAttribute("aria-pressed", subtreeMode ? "true" : "false");
|
||||
btn.classList.toggle("subtree-toggle--active", !subtreeMode);
|
||||
});
|
||||
};
|
||||
|
||||
refreshLabels();
|
||||
|
||||
buttons.forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
subtreeMode = !subtreeMode;
|
||||
persistSubtreeMode();
|
||||
refreshLabels();
|
||||
await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]);
|
||||
renderEvents();
|
||||
renderDeadlines();
|
||||
renderAppointments();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Breadcrumb + ancestor resolution -----------------------------------
|
||||
|
||||
function inheritedClientNumber(): string | null {
|
||||
@@ -1258,9 +1538,17 @@ function renderBreadcrumb() {
|
||||
el.innerHTML = crumbs.join(BREADCRUMB_CHEVRON);
|
||||
}
|
||||
|
||||
// ----- Children -----------------------------------------------------------
|
||||
// ----- Project Tree (Projektbaum) -----------------------------------------
|
||||
// Renders the full visible project hierarchy with the current node highlighted.
|
||||
// One round-trip to /api/projects/tree gets every project the user can see;
|
||||
// the renderer walks the tree and produces a nested <ul> with the current
|
||||
// node visually marked. Direct children of the current node still drive the
|
||||
// "no sub-projects" empty state, since that is the actionable signal for
|
||||
// the "+ Untervorhaben anlegen" CTA.
|
||||
|
||||
async function loadChildren(id: string) {
|
||||
// Direct children kept for the "Keine untergeordneten Projekte" empty state
|
||||
// and the create-new pre-fill (parent_id from the current node).
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/children`);
|
||||
if (resp.ok) children = ((await resp.json()) as ProjectMini[]) ?? [];
|
||||
@@ -1269,27 +1557,36 @@ async function loadChildren(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderChildren is the Projektbaum tab's mount point. m's 2026-05-08
|
||||
// 21:28: "should just be the same as the Tree in Projects. It has
|
||||
// symbols, everything." Reuse the /projects tree component
|
||||
// (project-tree.ts) verbatim — type icons, pin stars, deadline badges,
|
||||
// expand/collapse, search highlighting all come along for free. The
|
||||
// current project is highlighted via a CSS modifier we add to its
|
||||
// data-id row after the tree mounts.
|
||||
function renderChildren() {
|
||||
const list = document.getElementById("children-list")!;
|
||||
const root = document.getElementById("project-tree")!;
|
||||
const empty = document.getElementById("children-empty")!;
|
||||
if (!children.length) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = children
|
||||
.map(
|
||||
(c) => `<li class="projekt-child-item">
|
||||
<a href="/projects/${esc(c.id)}" class="projekt-child-link">
|
||||
<span class="entity-type-chip entity-type-${esc(c.type)}">${esc(tDyn("projects.type." + c.type) || c.type)}</span>
|
||||
<span class="projekt-child-title">${esc(c.title)}</span>
|
||||
${c.reference ? `<span class="projekt-child-ref">${esc(c.reference)}</span>` : ""}
|
||||
<span class="entity-status-chip entity-status-${esc(c.status)}">${esc(tDyn("projects.filter.status." + c.status) || c.status)}</span>
|
||||
</a>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
// Empty state only when the current node has zero direct children — the
|
||||
// CTA next to the empty message is "create sub-project", which would be
|
||||
// misleading if the tree itself has other branches.
|
||||
empty.style.display = children.length ? "none" : "";
|
||||
// Mount the shared tree. initProjectTree fetches /api/projects/tree on
|
||||
// first call and caches; subsequent tab-switches re-render from cache.
|
||||
// Set aria-current on the row matching this project — the shared tree
|
||||
// already styles aria-current=true with a lime highlight (global.css
|
||||
// .projekt-tree-node[aria-current="true"] > .projekt-tree-row).
|
||||
void initProjectTree(root).then(() => {
|
||||
const currentId = project?.id ?? "";
|
||||
if (!currentId) return;
|
||||
root.querySelectorAll<HTMLLIElement>(".projekt-tree-node").forEach((li) => {
|
||||
if (li.dataset.id === currentId) {
|
||||
li.setAttribute("aria-current", "true");
|
||||
} else {
|
||||
li.removeAttribute("aria-current");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initChildAddLink() {
|
||||
@@ -1310,6 +1607,59 @@ async function loadTeam(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-139 — Team-tab subsection loaders. All three are independent so
|
||||
// main() runs them in parallel.
|
||||
async function loadDescendantStaffed(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/team/from-descendants`);
|
||||
if (resp.ok) {
|
||||
descendantStaffed = ((await resp.json()) as ProjectTeamMember[]) ?? [];
|
||||
} else {
|
||||
descendantStaffed = [];
|
||||
}
|
||||
} catch {
|
||||
descendantStaffed = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDerivedMembers(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/team/derived`);
|
||||
if (resp.ok) {
|
||||
derivedMembers = ((await resp.json()) as DerivedMember[]) ?? [];
|
||||
} else {
|
||||
derivedMembers = [];
|
||||
}
|
||||
} catch {
|
||||
derivedMembers = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttachedUnits(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}/partner-units`);
|
||||
if (resp.ok) {
|
||||
attachedUnits = ((await resp.json()) as AttachedUnit[]) ?? [];
|
||||
} else {
|
||||
attachedUnits = [];
|
||||
}
|
||||
} catch {
|
||||
attachedUnits = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllUnits() {
|
||||
try {
|
||||
const resp = await fetch(`/api/partner-units`);
|
||||
if (resp.ok) {
|
||||
const all = (await resp.json()) as { id: string; name: string; office: string }[];
|
||||
allUnits = all ?? [];
|
||||
}
|
||||
} catch {
|
||||
allUnits = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserList() {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
@@ -1322,15 +1672,37 @@ async function loadUserList() {
|
||||
function renderTeam() {
|
||||
const body = document.getElementById("team-body")!;
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
if (!teamMembers.length) {
|
||||
|
||||
// Existing team-body shows the direct + ancestor-inherited members
|
||||
// returned by /api/projects/{id}/team. The derived + descendant
|
||||
// sections render into separate tbodies (added in TSX). Empty state
|
||||
// applies to the union — only show when EVERY section is empty.
|
||||
const totalRows =
|
||||
teamMembers.length + descendantStaffed.length + derivedMembers.length;
|
||||
if (totalRows === 0) {
|
||||
body.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
renderDescendantStaffed();
|
||||
renderDerivedMembers();
|
||||
renderAttachedUnits();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
|
||||
// t-paliad-148: profession is firm-wide (read-only badge) and
|
||||
// responsibility is per-project. Both are surfaced; the legacy
|
||||
// .role field is still set by the server during the deprecation
|
||||
// window but the UI ignores it.
|
||||
const responsibility = m.responsibility || "member";
|
||||
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
|
||||
const professionLabel = m.user_profession
|
||||
? tDyn(`projects.team.profession.${m.user_profession}`) || m.user_profession
|
||||
: (t("projects.team.profession.none") || "(extern)");
|
||||
const professionTitle = m.user_profession
|
||||
? (t("projects.team.profession.hint") || "Profession — gesetzt im Firmenprofil")
|
||||
: (t("projects.team.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis");
|
||||
const source = m.inherited
|
||||
? `<span class="projekt-team-inherited" title="${escAttr(t("projects.team.inherited.hint") || "Inherited from ancestor")}">
|
||||
↑ ${esc(m.inherited_from_title || "")}
|
||||
@@ -1341,10 +1713,12 @@ function renderTeam() {
|
||||
? `<button type="button" class="btn-ghost btn-small team-remove-btn" data-user-id="${esc(m.user_id)}">${esc(t("projects.detail.team.remove") || "Entfernen")}</button>`
|
||||
: "";
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
@@ -1366,6 +1740,146 @@ function renderTeam() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
renderDescendantStaffed();
|
||||
renderDerivedMembers();
|
||||
renderAttachedUnits();
|
||||
}
|
||||
|
||||
// t-paliad-139 — "Aus Unterprojekten" subsection.
|
||||
function renderDescendantStaffed() {
|
||||
const section = document.getElementById("team-section-descendants");
|
||||
const body = document.getElementById("team-descendants-body");
|
||||
if (!section || !body) return;
|
||||
if (descendantStaffed.length === 0) {
|
||||
section.style.display = "none";
|
||||
body.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
body.innerHTML = descendantStaffed
|
||||
.map((m) => {
|
||||
const responsibility = m.responsibility || "member";
|
||||
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const sourceTitle = esc(m.inherited_from_title || "");
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td><span class="projekt-team-inherited" title="${escAttr(t("aggregation.attribution.on") || "auf")}: ${sourceTitle}">↓ ${sourceTitle}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// t-paliad-139 — "Abgeleitet (Partner Unit)" subsection.
|
||||
function renderDerivedMembers() {
|
||||
const section = document.getElementById("team-section-derived");
|
||||
const body = document.getElementById("team-derived-body");
|
||||
if (!section || !body) return;
|
||||
if (derivedMembers.length === 0) {
|
||||
section.style.display = "none";
|
||||
body.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
body.innerHTML = derivedMembers
|
||||
.map((m) => {
|
||||
const memberships = m.memberships || [];
|
||||
// Role column shows distinct unit_role values (usually one — only
|
||||
// diverges if the user has different roles in different units).
|
||||
const distinctRoles = Array.from(new Set(memberships.map((x) => x.unit_role)));
|
||||
const roleLabel = distinctRoles
|
||||
.map((r) => tDyn(`unit_role.${r}`) || r)
|
||||
.join(", ");
|
||||
// Herkunft column lists every (unit, role) pair so multi-unit users
|
||||
// surface all their sources, not just the closest one (t-paliad-143).
|
||||
// Multi-unit: bold each unit name and append the role in parentheses.
|
||||
// Single-unit: bold the one unit name (matches the legacy rendering).
|
||||
const sourceLabel = memberships
|
||||
.map((x) => {
|
||||
const name = `<strong>${esc(x.unit_name)}</strong>`;
|
||||
if (memberships.length === 1) return name;
|
||||
const role = esc(tDyn(`unit_role.${x.unit_role}`) || x.unit_role);
|
||||
return `${name} (${role})`;
|
||||
})
|
||||
.join(", ");
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const authBadge = m.derive_grants_authority
|
||||
? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>`
|
||||
: `<span class="derived-badge">${esc(t("projects.team.derived.visibility") || "Sicht")}</span>`;
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td>${esc(t("projects.team.derived.from") || "über")}: ${sourceLabel} ${authBadge}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// t-paliad-139 — Partner Units management section. Lists attached units
|
||||
// with detach buttons; admin/lead can add new attachments.
|
||||
function renderAttachedUnits() {
|
||||
const section = document.getElementById("team-section-units");
|
||||
const body = document.getElementById("team-units-body");
|
||||
if (!section || !body) return;
|
||||
const canManage = canManagePartnerUnits();
|
||||
// Always show the section to admins/leads (even if empty so they can attach).
|
||||
if (!canManage && attachedUnits.length === 0) {
|
||||
section.style.display = "none";
|
||||
return;
|
||||
}
|
||||
section.style.display = "";
|
||||
if (attachedUnits.length === 0) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="form-hint">${esc(t("projects.team.units.empty") || "Keine Partner Units zugeordnet.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = attachedUnits
|
||||
.map((u) => {
|
||||
const roles = (u.derive_unit_roles || []).map((r) => tDyn(`unit_role.${r}`) || r).join(", ");
|
||||
const auth = u.derive_grants_authority
|
||||
? esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")
|
||||
: esc(t("projects.team.derived.visibility") || "Sicht");
|
||||
const detachBtn = canManage
|
||||
? `<button type="button" class="btn-ghost btn-small unit-detach-btn" data-unit-id="${esc(u.partner_unit_id)}">${esc(t("projects.team.units.detach") || "Entfernen")}</button>`
|
||||
: "";
|
||||
return `<tr>
|
||||
<td><strong>${esc(u.unit_name)}</strong></td>
|
||||
<td>${esc(roles)}</td>
|
||||
<td>${auth}</td>
|
||||
<td>${u.derived_member_count} ${esc(t("projects.team.units.members") || "Mitglieder")} ${detachBtn}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".unit-detach-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!project) return;
|
||||
const unitID = btn.dataset.unitId!;
|
||||
if (!window.confirm(t("projects.team.units.confirm_detach") || "Partner Unit entfernen?")) return;
|
||||
const resp = await fetch(
|
||||
`/api/projects/${project.id}/partner-units/${encodeURIComponent(unitID)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
await Promise.all([loadAttachedUnits(project.id), loadDerivedMembers(project.id)]);
|
||||
renderTeam();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// canManagePartnerUnits returns true for global_admin or this project's
|
||||
// lead. Mirrors the migration-055 RLS write policy.
|
||||
function canManagePartnerUnits(): boolean {
|
||||
if (!me) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
if (!project) return false;
|
||||
return teamMembers.some(
|
||||
(m) => m.user_id === me!.id && m.responsibility === "lead" && m.project_id === project!.id,
|
||||
);
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
@@ -1382,8 +1896,25 @@ function initTeamForm(id: string) {
|
||||
const hidden = document.getElementById("team-user-id") as HTMLInputElement | null;
|
||||
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
const role = document.getElementById("team-role") as HTMLSelectElement | null;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
|
||||
const responsibility = document.getElementById("team-responsibility") as HTMLSelectElement | null;
|
||||
const professionHint = document.getElementById("team-profession-hint") as HTMLParagraphElement | null;
|
||||
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
|
||||
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
|
||||
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !responsibility) return;
|
||||
|
||||
const hideInviteHint = () => {
|
||||
if (inviteHint) inviteHint.style.display = "none";
|
||||
};
|
||||
const showInviteHint = (q: string) => {
|
||||
if (!inviteHint || !inviteHintText) return;
|
||||
const looksLikeEmail = /@/.test(q) && /\./.test(q.split("@")[1] || "");
|
||||
inviteHintText.textContent = looksLikeEmail
|
||||
? t("projects.detail.team.invite.hint_email") || "Niemand mit dieser E-Mail."
|
||||
: t("projects.detail.team.invite.hint") || "Benutzer nicht gefunden?";
|
||||
inviteHint.dataset.email = looksLikeEmail ? q : "";
|
||||
inviteHint.style.display = "";
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
form.style.display = "";
|
||||
@@ -1396,18 +1927,21 @@ function initTeamForm(id: string) {
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
msg.textContent = "";
|
||||
});
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
const q = input.value.trim();
|
||||
const lc = q.toLowerCase();
|
||||
hidden.value = "";
|
||||
if (!q) {
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
return;
|
||||
}
|
||||
const matches = userOptions
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(lc))
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
@@ -1422,8 +1956,47 @@ function initTeamForm(id: string) {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
// t-paliad-148: surface the picked person's profession so the
|
||||
// adder sees what firm tier they're staffing on this matter,
|
||||
// and gets a warning when the user has no profession set.
|
||||
if (professionHint) {
|
||||
const picked = userOptions.find((u) => u.id === hidden.value);
|
||||
const prof = picked?.profession;
|
||||
if (!prof) {
|
||||
professionHint.textContent = t("projects.detail.team.form.profession.none") ||
|
||||
"Keine Profession gesetzt — kann keine 4-Augen-Genehmigungen erteilen.";
|
||||
professionHint.className = "form-hint form-hint--warning";
|
||||
professionHint.style.display = "";
|
||||
} else {
|
||||
const profLabel = tDyn(`projects.team.profession.${prof}`) || prof;
|
||||
professionHint.textContent = `${t("projects.detail.team.form.profession.label") || "Profession"}: ${profLabel}`;
|
||||
professionHint.className = "form-hint";
|
||||
professionHint.style.display = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
showInviteHint(q);
|
||||
} else {
|
||||
hideInviteHint();
|
||||
}
|
||||
});
|
||||
|
||||
inviteBtn?.addEventListener("click", () => {
|
||||
const sidebarBtn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
|
||||
if (!sidebarBtn) return;
|
||||
sidebarBtn.click();
|
||||
const prefill = inviteHint?.dataset.email || "";
|
||||
if (prefill) {
|
||||
const inviteEmail = document.getElementById("invite-email") as HTMLInputElement | null;
|
||||
if (inviteEmail) {
|
||||
inviteEmail.value = prefill;
|
||||
inviteEmail.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
@@ -1436,7 +2009,7 @@ function initTeamForm(id: string) {
|
||||
const resp = await fetch(`/api/projects/${id}/team`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value, role: role.value }),
|
||||
body: JSON.stringify({ user_id: hidden.value, responsibility: responsibility.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const b = await resp.json().catch(() => ({ error: "unknown" }));
|
||||
@@ -1446,6 +2019,7 @@ function initTeamForm(id: string) {
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
hideInviteHint();
|
||||
form.style.display = "none";
|
||||
addBtn.style.display = "";
|
||||
await loadTeam(id);
|
||||
|
||||
81
frontend/src/client/projects-flat.ts
Normal file
81
frontend/src/client/projects-flat.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { tDyn, getLang } from "./i18n";
|
||||
|
||||
// Flat-list (table) rendering for /projects.
|
||||
// Extracted from the pre-t-paliad-149 client/projects.ts so the orchestrator
|
||||
// can mount/unmount table view alongside the tree view without code duplication.
|
||||
|
||||
export interface ProjectFlatRow {
|
||||
id: string;
|
||||
type: string;
|
||||
parent_id?: string | null;
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
rows: ProjectFlatRow[];
|
||||
}
|
||||
|
||||
// renderFlatList writes the table rows + wires row-click navigation.
|
||||
// Caller is responsible for showing/hiding the wrapping table element.
|
||||
export function renderFlatList(opts: RenderOpts) {
|
||||
const tbody = document.getElementById("projects-body")!;
|
||||
tbody.innerHTML = opts.rows
|
||||
.map((p) => {
|
||||
const typeLabel = tDyn(`projects.type.${p.type}`) || p.type;
|
||||
const statusLabel = tDyn(`projects.filter.status.${p.status}`) || p.status;
|
||||
const clientMatter =
|
||||
p.client_number && p.matter_number
|
||||
? `${p.client_number}.${p.matter_number}`
|
||||
: p.client_number || p.matter_number || "";
|
||||
const refCell = p.reference ? esc(p.reference) : "—";
|
||||
const clientMatterCell = clientMatter ? esc(clientMatter) : "—";
|
||||
return `<tr class="entity-row" data-id="${esc(p.id)}">
|
||||
<td class="entity-col-title">${esc(p.title)}</td>
|
||||
<td><span class="entity-type-chip entity-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
||||
<td class="entity-col-ref">${refCell}</td>
|
||||
<td class="entity-col-ref">${clientMatterCell}</td>
|
||||
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
|
||||
<td class="entity-col-updated">${fmtDate(p.updated_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// F-23: when every visible row shares the same status, hide the column to
|
||||
// cut redundant noise. The toggle re-runs on every filter change, so the
|
||||
// column comes back as soon as the rows mix again.
|
||||
const statusUnique = new Set(opts.rows.map((p) => p.status)).size;
|
||||
const table = document.getElementById("entity-table");
|
||||
table?.classList.toggle("entity-table--hide-status", statusUnique <= 1);
|
||||
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".entity-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.id!;
|
||||
window.location.href = `/projects/${id}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
@@ -17,6 +17,21 @@ function $(id: string): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
// sanitizeReturnUrl restricts the post-create bounce-back to same-origin
|
||||
// paths. Any value that could escape to a different origin (protocol-
|
||||
// relative `//foo`, absolute `https://...`, or non-rooted relative
|
||||
// paths) is rejected and the form falls back to /projects/{id}. m's
|
||||
// 2026-05-08 Determinator Slice 2: the /tools/fristenrechner Step 1
|
||||
// "Neue Akte anlegen" link sends ?return=/tools/fristenrechner so the
|
||||
// new project preselects itself when control bounces back.
|
||||
function sanitizeReturnUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith("//")) return null;
|
||||
if (raw.includes("://")) return null;
|
||||
if (!raw.startsWith("/")) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
const form = $("project-new-form") as HTMLFormElement;
|
||||
const msg = $("project-new-msg") as HTMLParagraphElement;
|
||||
@@ -41,6 +56,20 @@ function submitForm() {
|
||||
return;
|
||||
}
|
||||
const p = (await resp.json()) as { id: string };
|
||||
|
||||
// Honour ?return=<path> if it's a same-origin rooted path. The
|
||||
// caller is responsible for ensuring the destination knows what
|
||||
// to do with the appended ?project= param; see Slice 1's Step 1
|
||||
// hydration.
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const returnUrl = sanitizeReturnUrl(qs.get("return"));
|
||||
if (returnUrl) {
|
||||
const dest = new URL(returnUrl, window.location.origin);
|
||||
dest.searchParams.set("project", p.id);
|
||||
window.location.href = dest.pathname + dest.search + dest.hash;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/projects/${p.id}`;
|
||||
} catch (e) {
|
||||
msg.textContent = String(e);
|
||||
|
||||
@@ -1,71 +1,318 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initProjectTree, rerenderProjectTree } from "./project-tree";
|
||||
import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree";
|
||||
import { renderFlatList, ProjectFlatRow } from "./projects-flat";
|
||||
import { renderCardsView, teardownCardsView } from "./projects-cards";
|
||||
|
||||
// /projekte list page client. Reads v2 shape from /api/projects.
|
||||
interface Project {
|
||||
id: string;
|
||||
type: string;
|
||||
parent_id?: string | null;
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
updated_at: string;
|
||||
// /projects orchestrator (t-paliad-149).
|
||||
//
|
||||
// Owns:
|
||||
// - chip state (scope + status + type + pinned + has_open_deadlines)
|
||||
// - search term (in-place filter, server-side)
|
||||
// - view mode (tree | flat). Cards lands in PR 2.
|
||||
// - last-view restore + URL params (Q1 lock-in: last-viewed restore).
|
||||
//
|
||||
// Delegates rendering to:
|
||||
// - project-tree.ts for tree mode
|
||||
// - projects-flat.ts for flat-table mode
|
||||
|
||||
type ViewMode = "tree" | "cards" | "flat";
|
||||
type Scope = "all" | "mine" | "pinned";
|
||||
|
||||
interface Chips {
|
||||
scope: Scope;
|
||||
status: Set<string>;
|
||||
type: Set<string>;
|
||||
hasOpenDeadlines: boolean;
|
||||
}
|
||||
|
||||
let allRows: Project[] = [];
|
||||
let typeFilter = "";
|
||||
let statusFilter = "";
|
||||
let viewMode: "flat" | "tree" | "roots" = parseInitialView();
|
||||
let searchQuery = "";
|
||||
let loadedOK = false;
|
||||
|
||||
// Honour ?view=flat|tree|roots from the URL so dashboard links and bookmarks
|
||||
// land on the right layout. Anything else falls back to "flat".
|
||||
function parseInitialView(): "flat" | "tree" | "roots" {
|
||||
const v = new URLSearchParams(window.location.search).get("view");
|
||||
if (v === "tree" || v === "roots" || v === "flat") return v;
|
||||
return "flat";
|
||||
interface State {
|
||||
viewMode: ViewMode;
|
||||
chips: Chips;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
async function loadProjekte() {
|
||||
const STORAGE_KEY = "paliad.projects.lastView";
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
let state: State = defaultState();
|
||||
let flatRows: ProjectFlatRow[] | null = null;
|
||||
let searchDebounce: number | null = null;
|
||||
|
||||
function defaultState(): State {
|
||||
return {
|
||||
viewMode: "tree",
|
||||
chips: {
|
||||
scope: "all",
|
||||
status: new Set(),
|
||||
type: new Set(),
|
||||
hasOpenDeadlines: false,
|
||||
},
|
||||
searchQuery: "",
|
||||
};
|
||||
}
|
||||
|
||||
function loadStoredState(): State | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
viewMode?: ViewMode;
|
||||
chips?: { scope?: Scope; status?: string[]; type?: string[]; hasOpenDeadlines?: boolean };
|
||||
searchQuery?: string;
|
||||
};
|
||||
const viewMode: ViewMode =
|
||||
parsed.viewMode === "flat" ? "flat" :
|
||||
parsed.viewMode === "cards" ? "cards" :
|
||||
"tree";
|
||||
return {
|
||||
viewMode,
|
||||
chips: {
|
||||
scope: parsed.chips?.scope === "mine" || parsed.chips?.scope === "pinned" ? parsed.chips.scope : "all",
|
||||
status: new Set(parsed.chips?.status || []),
|
||||
type: new Set(parsed.chips?.type || []),
|
||||
hasOpenDeadlines: !!parsed.chips?.hasOpenDeadlines,
|
||||
},
|
||||
searchQuery: typeof parsed.searchQuery === "string" ? parsed.searchQuery : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
viewMode: state.viewMode,
|
||||
chips: {
|
||||
scope: state.chips.scope,
|
||||
status: [...state.chips.status],
|
||||
type: [...state.chips.type],
|
||||
hasOpenDeadlines: state.chips.hasOpenDeadlines,
|
||||
},
|
||||
searchQuery: state.searchQuery,
|
||||
}));
|
||||
} catch {
|
||||
/* private mode, quota — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// applyURL overlays ?view=, ?scope=, ?status=, ?type=, ?has_open_deadlines=,
|
||||
// ?q= onto the current state. URL > sessionStorage > defaults.
|
||||
function applyURL() {
|
||||
const url = new URL(window.location.href);
|
||||
const v = url.searchParams.get("view");
|
||||
if (v === "tree" || v === "flat" || v === "cards") state.viewMode = v;
|
||||
const sc = url.searchParams.get("scope");
|
||||
if (sc === "mine" || sc === "pinned" || sc === "all") state.chips.scope = sc;
|
||||
const status = url.searchParams.get("status");
|
||||
if (status !== null) {
|
||||
state.chips.status = new Set(status.split(",").map((s) => s.trim()).filter(Boolean));
|
||||
}
|
||||
const type = url.searchParams.get("type");
|
||||
if (type !== null) {
|
||||
state.chips.type = new Set(type.split(",").map((s) => s.trim()).filter(Boolean));
|
||||
}
|
||||
const has = url.searchParams.get("has_open_deadlines");
|
||||
if (has === "true" || has === "false") state.chips.hasOpenDeadlines = has === "true";
|
||||
const q = url.searchParams.get("q");
|
||||
if (q !== null) state.searchQuery = q;
|
||||
}
|
||||
|
||||
function syncURL() {
|
||||
const url = new URL(window.location.href);
|
||||
// Clear all known params, then re-set only the non-default ones (keeps URLs short).
|
||||
["view", "scope", "status", "type", "has_open_deadlines", "q"].forEach((k) => url.searchParams.delete(k));
|
||||
if (state.viewMode !== "tree") url.searchParams.set("view", state.viewMode);
|
||||
if (state.chips.scope !== "all") url.searchParams.set("scope", state.chips.scope);
|
||||
if (state.chips.status.size > 0) url.searchParams.set("status", [...state.chips.status].join(","));
|
||||
if (state.chips.type.size > 0) url.searchParams.set("type", [...state.chips.type].join(","));
|
||||
if (state.chips.hasOpenDeadlines) url.searchParams.set("has_open_deadlines", "true");
|
||||
if (state.searchQuery.trim()) url.searchParams.set("q", state.searchQuery.trim());
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
// Build the query string the tree endpoint expects. Same shape as the URL
|
||||
// state but always written (we don't omit "all" because the server expects
|
||||
// ?subtree_counts=true to get the new field).
|
||||
function treeParams(): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (state.chips.scope !== "all") p.set("scope", state.chips.scope);
|
||||
if (state.chips.status.size > 0) p.set("status", [...state.chips.status].join(","));
|
||||
if (state.chips.type.size > 0) p.set("type", [...state.chips.type].join(","));
|
||||
if (state.chips.hasOpenDeadlines) p.set("has_open_deadlines", "true");
|
||||
if (state.searchQuery.trim()) p.set("q", state.searchQuery.trim());
|
||||
p.set("subtree_counts", "true");
|
||||
return p;
|
||||
}
|
||||
|
||||
function reflectChipsToDOM() {
|
||||
// Scope toggles
|
||||
const scopes: Scope[] = ["all", "mine", "pinned"];
|
||||
scopes.forEach((s) => {
|
||||
const btn = document.querySelector<HTMLButtonElement>(`.projects-chip[data-chip="${s}"]`);
|
||||
btn?.classList.toggle("is-active", state.chips.scope === s);
|
||||
});
|
||||
// Has-open-deadlines
|
||||
const hasBtn = document.querySelector<HTMLButtonElement>(`.projects-chip[data-chip="has_open_deadlines"]`);
|
||||
hasBtn?.classList.toggle("is-active", state.chips.hasOpenDeadlines);
|
||||
|
||||
// Multi-select panels
|
||||
reflectMulti("status", state.chips.status);
|
||||
reflectMulti("type", state.chips.type);
|
||||
|
||||
// View mode segment-control
|
||||
document.querySelectorAll<HTMLButtonElement>(".projects-view-btn").forEach((btn) => {
|
||||
btn.classList.toggle("is-active", btn.dataset.view === state.viewMode);
|
||||
});
|
||||
|
||||
// Search input value (when restoring state on init)
|
||||
const searchInput = document.getElementById("projects-search") as HTMLInputElement | null;
|
||||
if (searchInput && searchInput.value !== state.searchQuery) {
|
||||
searchInput.value = state.searchQuery;
|
||||
}
|
||||
}
|
||||
|
||||
function reflectMulti(name: string, set: Set<string>) {
|
||||
const wrap = document.querySelector<HTMLDetailsElement>(`.projects-chip-multi[data-chip-multi="${name}"]`);
|
||||
if (!wrap) return;
|
||||
const summary = wrap.querySelector<HTMLElement>("summary");
|
||||
const inputs = wrap.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
|
||||
inputs.forEach((cb) => { cb.checked = set.has(cb.value); });
|
||||
if (summary) {
|
||||
summary.classList.toggle("is-active", set.size > 0);
|
||||
const baseLabel = t(`projects.chip.${name}` as never) || (name === "status" ? "Status" : "Typ");
|
||||
if (set.size === 0) {
|
||||
summary.textContent = String(baseLabel);
|
||||
} else if (set.size === 1) {
|
||||
const sole = [...set][0];
|
||||
const labelKey = `projects.chip.${name}.${sole}` as never;
|
||||
const label = t(labelKey) || sole;
|
||||
summary.textContent = `${baseLabel}: ${label}`;
|
||||
} else {
|
||||
const tmpl = t("projects.chip.multi.count" as never) || "{n} ausgewählt";
|
||||
summary.textContent = `${baseLabel}: ${String(tmpl).replace("{n}", String(set.size))}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setScope(s: Scope) {
|
||||
state.chips.scope = s;
|
||||
postChipChange();
|
||||
}
|
||||
|
||||
function toggleHasOpen() {
|
||||
state.chips.hasOpenDeadlines = !state.chips.hasOpenDeadlines;
|
||||
postChipChange();
|
||||
}
|
||||
|
||||
function postChipChange() {
|
||||
syncURL();
|
||||
saveState();
|
||||
reflectChipsToDOM();
|
||||
void render();
|
||||
}
|
||||
|
||||
function clearAllChips() {
|
||||
state = { ...state, chips: defaultState().chips, searchQuery: "" };
|
||||
postChipChange();
|
||||
const searchInput = document.getElementById("projects-search") as HTMLInputElement | null;
|
||||
if (searchInput) searchInput.value = "";
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const treeWrap = document.getElementById("projekt-tree-wrap")!;
|
||||
const tableWrap = document.getElementById("entity-table-wrap")!;
|
||||
const cardsWrap = document.getElementById("projects-cards-wrap")!;
|
||||
const empty = document.getElementById("entity-empty")!;
|
||||
const emptyFiltered = document.getElementById("entity-empty-filtered")!;
|
||||
|
||||
if (state.viewMode === "tree") {
|
||||
teardownCardsView();
|
||||
treeWrap.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
cardsWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
const container = document.getElementById("projekt-tree-container") as HTMLElement;
|
||||
await initProjectTree(container, treeParams());
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "cards") {
|
||||
treeWrap.style.display = "none";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
await renderCardsView({ treeParams: treeParams() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Flat-list mode. Reuses /api/projects (existing flat endpoint).
|
||||
teardownCardsView();
|
||||
treeWrap.style.display = "none";
|
||||
if (!flatRows) {
|
||||
flatRows = await loadFlatRows();
|
||||
}
|
||||
if (!flatRows) {
|
||||
tableWrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const filtered = filterFlatRows(flatRows);
|
||||
const count = document.getElementById("projects-count")!;
|
||||
count.textContent = `${filtered.length} / ${flatRows.length}`;
|
||||
if (flatRows.length === 0) {
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
emptyFiltered.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "block";
|
||||
return;
|
||||
}
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
renderFlatList({ rows: filtered });
|
||||
}
|
||||
|
||||
async function loadFlatRows(): Promise<ProjectFlatRow[] | null> {
|
||||
const unavailable = document.getElementById("entity-unavailable")!;
|
||||
const table = document.querySelector<HTMLElement>(".entity-table-wrap")!;
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.status === 503) {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
document.getElementById("entity-empty")!.style.display = "none";
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
allRows = await resp.json();
|
||||
loadedOK = true;
|
||||
render();
|
||||
return (await resp.json()) as ProjectFlatRow[];
|
||||
} catch {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getFiltered(): Project[] {
|
||||
// Tree view is handled by the dedicated tree module. Filters and search
|
||||
// here only apply to the flat list.
|
||||
let rows = allRows;
|
||||
if (viewMode === "roots") rows = rows.filter((p) => !p.parent_id);
|
||||
if (typeFilter) rows = rows.filter((p) => p.type === typeFilter);
|
||||
if (statusFilter) rows = rows.filter((p) => p.status === statusFilter);
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
rows = rows.filter((p) => {
|
||||
function filterFlatRows(rows: ProjectFlatRow[]): ProjectFlatRow[] {
|
||||
let out = rows;
|
||||
if (state.chips.status.size > 0) {
|
||||
out = out.filter((p) => state.chips.status.has(p.status));
|
||||
}
|
||||
if (state.chips.type.size > 0) {
|
||||
out = out.filter((p) => state.chips.type.has(p.type));
|
||||
}
|
||||
// Note: scope=mine / scope=pinned / has_open_deadlines are not applied
|
||||
// to the flat-list view — those need server-side support and the flat
|
||||
// endpoint /api/projects is unchanged from pre-redesign. The chips simply
|
||||
// narrow status + type in flat mode; tree mode honours all chips.
|
||||
if (state.searchQuery.trim()) {
|
||||
const q = state.searchQuery.toLowerCase();
|
||||
out = out.filter((p) => {
|
||||
const haystack = [
|
||||
p.title,
|
||||
p.reference || "",
|
||||
@@ -77,162 +324,100 @@ function getFiltered(): Project[] {
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
const tbody = document.getElementById("projects-body")!;
|
||||
const empty = document.getElementById("entity-empty")!;
|
||||
const emptyFiltered = document.getElementById("entity-empty-filtered")!;
|
||||
const tableWrap = document.getElementById("entity-table-wrap")!;
|
||||
const treeWrap = document.getElementById("projekt-tree-wrap")!;
|
||||
const count = document.getElementById("projects-count")!;
|
||||
|
||||
if (viewMode === "tree") {
|
||||
// Tree view is rendered by project-tree.ts; reflect the toggle state here
|
||||
// and let it handle its own data fetch (separate /api/projects/tree call).
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = allRows.length === 0 ? "block" : "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
treeWrap.style.display = allRows.length === 0 ? "none" : "block";
|
||||
// Match the flat-view "X / Y" format so the counter reads consistently
|
||||
// when toggling between views (F-39). Tree view shows everything, so the
|
||||
// numerator equals the total.
|
||||
count.textContent = `${allRows.length} / ${allRows.length}`;
|
||||
if (allRows.length > 0) {
|
||||
const container = document.getElementById("projekt-tree-container") as HTMLElement;
|
||||
void initProjectTree(container);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
treeWrap.style.display = "none";
|
||||
|
||||
const filtered = getFiltered();
|
||||
count.textContent = `${filtered.length} / ${allRows.length}`;
|
||||
|
||||
if (allRows.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
emptyFiltered.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
|
||||
tbody.innerHTML = filtered
|
||||
.map((p) => {
|
||||
const typeLabel = tDyn(`projects.type.${p.type}`) || p.type;
|
||||
const statusLabel = tDyn(`projects.filter.status.${p.status}`) || p.status;
|
||||
const clientMatter =
|
||||
p.client_number && p.matter_number
|
||||
? `${p.client_number}.${p.matter_number}`
|
||||
: p.client_number || p.matter_number || "";
|
||||
// Empty cells render an em-dash to match the rest of the app (F-28).
|
||||
const refCell = p.reference ? esc(p.reference) : "—";
|
||||
const clientMatterCell = clientMatter ? esc(clientMatter) : "—";
|
||||
return `<tr class="entity-row" data-id="${esc(p.id)}">
|
||||
<td class="entity-col-title">${esc(p.title)}</td>
|
||||
<td><span class="entity-type-chip entity-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
||||
<td class="entity-col-ref">${refCell}</td>
|
||||
<td class="entity-col-ref">${clientMatterCell}</td>
|
||||
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
|
||||
<td class="entity-col-updated">${fmtDate(p.updated_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// F-23: when every visible row shares the same status, hide the column to
|
||||
// cut redundant noise. The toggle re-runs on every filter change, so the
|
||||
// column comes back as soon as the rows mix again.
|
||||
const statusUnique = new Set(filtered.map((p) => p.status)).size;
|
||||
const table = document.getElementById("entity-table");
|
||||
table?.classList.toggle("entity-table--hide-status", statusUnique <= 1);
|
||||
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".entity-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.id!;
|
||||
window.location.href = `/projects/${id}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
return out;
|
||||
}
|
||||
|
||||
function initSearch() {
|
||||
const input = document.getElementById("projects-search") as HTMLInputElement;
|
||||
const input = document.getElementById("projects-search") as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.addEventListener("input", () => {
|
||||
searchQuery = input.value.trim();
|
||||
render();
|
||||
if (searchDebounce !== null) {
|
||||
window.clearTimeout(searchDebounce);
|
||||
}
|
||||
searchDebounce = window.setTimeout(() => {
|
||||
state.searchQuery = input.value;
|
||||
syncURL();
|
||||
saveState();
|
||||
void render();
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
const typeSel = document.getElementById("project-type") as HTMLSelectElement;
|
||||
const status = document.getElementById("project-status") as HTMLSelectElement;
|
||||
const view = document.getElementById("project-view") as HTMLSelectElement;
|
||||
view.value = viewMode;
|
||||
typeSel.addEventListener("change", () => {
|
||||
typeFilter = typeSel.value;
|
||||
render();
|
||||
function initChips() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".projects-chip[data-chip]").forEach((btn) => {
|
||||
const chip = btn.dataset.chip!;
|
||||
if (chip === "all") {
|
||||
btn.addEventListener("click", () => clearAllChips());
|
||||
} else if (chip === "mine") {
|
||||
btn.addEventListener("click", () => setScope(state.chips.scope === "mine" ? "all" : "mine"));
|
||||
} else if (chip === "pinned") {
|
||||
btn.addEventListener("click", () => setScope(state.chips.scope === "pinned" ? "all" : "pinned"));
|
||||
} else if (chip === "has_open_deadlines") {
|
||||
btn.addEventListener("click", () => toggleHasOpen());
|
||||
}
|
||||
});
|
||||
status.addEventListener("change", () => {
|
||||
statusFilter = status.value;
|
||||
render();
|
||||
});
|
||||
view.addEventListener("change", () => {
|
||||
viewMode = view.value as "flat" | "tree" | "roots";
|
||||
syncViewQuery();
|
||||
render();
|
||||
|
||||
// Multi-select panels — wire each checkbox change.
|
||||
document.querySelectorAll<HTMLDetailsElement>(".projects-chip-multi").forEach((wrap) => {
|
||||
const name = wrap.dataset.chipMulti!;
|
||||
const set = (name === "status" ? state.chips.status : state.chips.type);
|
||||
wrap.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
if (cb.checked) set.add(cb.value); else set.delete(cb.value);
|
||||
postChipChange();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const reset = document.getElementById("projects-reset-filters");
|
||||
if (reset) reset.addEventListener("click", () => clearAllChips());
|
||||
}
|
||||
|
||||
// Mirror viewMode into ?view= so the URL is shareable. Default "flat" stays
|
||||
// implicit (drop the param) to keep the canonical path clean.
|
||||
function syncViewQuery() {
|
||||
const url = new URL(window.location.href);
|
||||
if (viewMode === "flat") url.searchParams.delete("view");
|
||||
else url.searchParams.set("view", viewMode);
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
function initViewSegment() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".projects-view-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.view as ViewMode;
|
||||
if (v !== "tree" && v !== "flat" && v !== "cards") return;
|
||||
if (state.viewMode === v) return;
|
||||
state.viewMode = v;
|
||||
syncURL();
|
||||
saveState();
|
||||
reflectChipsToDOM();
|
||||
void render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
// Q1 lock-in: last-viewed restore. URL > sessionStorage > defaults.
|
||||
const stored = loadStoredState();
|
||||
if (stored) state = stored;
|
||||
applyURL();
|
||||
reflectChipsToDOM();
|
||||
initSearch();
|
||||
initFilters();
|
||||
initChips();
|
||||
initViewSegment();
|
||||
onLangChange(() => {
|
||||
render();
|
||||
if (viewMode === "tree") rerenderProjectTree();
|
||||
reflectChipsToDOM();
|
||||
if (state.viewMode === "tree") {
|
||||
rerenderProjectTree();
|
||||
} else {
|
||||
void render();
|
||||
}
|
||||
});
|
||||
void render();
|
||||
// The pin handler in project-tree.ts mutates the per-node cache and then
|
||||
// invalidates it, so subsequent chip changes refetch with fresh pin data.
|
||||
// When the user navigates back to /projects via popstate (in-app links),
|
||||
// re-apply URL state.
|
||||
window.addEventListener("popstate", () => {
|
||||
state = loadStoredState() || defaultState();
|
||||
applyURL();
|
||||
reflectChipsToDOM();
|
||||
refreshProjectTree(treeParams());
|
||||
flatRows = null;
|
||||
void render();
|
||||
});
|
||||
loadProjekte();
|
||||
});
|
||||
|
||||
@@ -70,7 +70,10 @@ export function initSidebar() {
|
||||
initInviteModal();
|
||||
initGlobalSearch();
|
||||
initChangelogBadge();
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initPaliadinLinks();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
@@ -314,6 +317,33 @@ function initChangelogBadge(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Inbox badge (t-paliad-138) — count of approval requests where the
|
||||
// current user is qualified to approve. Polls every 60s while the page
|
||||
// is open. Silently swallows errors (badge is optional).
|
||||
function initInboxBadge(): void {
|
||||
const badge = document.getElementById("sidebar-inbox-badge") as HTMLElement | null;
|
||||
if (!badge) return;
|
||||
|
||||
const refresh = () => {
|
||||
fetch("/api/inbox/count", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { count?: number } | null) => {
|
||||
if (!data || typeof data.count !== "number" || data.count <= 0) {
|
||||
badge.style.display = "none";
|
||||
return;
|
||||
}
|
||||
badge.textContent = data.count > 9 ? "9+" : String(data.count);
|
||||
badge.style.display = "";
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent */
|
||||
});
|
||||
};
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 60_000);
|
||||
}
|
||||
|
||||
// initThemeToggle wires the sun/moon button at the bottom of the sidebar
|
||||
// (m/paliad#2). The pre-paint inline script in PWAHead.tsx already set
|
||||
// the data-theme attribute on <html>; this function only owns the post-
|
||||
@@ -372,6 +402,148 @@ function initThemeToggle(): void {
|
||||
render();
|
||||
}
|
||||
|
||||
// t-paliad-144 Phase A2 — Meine Sichten group hydration. Fetches the
|
||||
// caller's saved views and renders one nav item per view between the
|
||||
// group label and the "+ Neue Sicht" trailing entry. Optional count
|
||||
// badge per view (when show_count=true on the row). The "+ Neue Sicht"
|
||||
// entry stays in the DOM unconditionally so the group has something
|
||||
// to show even for first-time users.
|
||||
interface UserViewLite {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
show_count: boolean;
|
||||
}
|
||||
|
||||
function initUserViewsGroup(): void {
|
||||
const items = document.getElementById("sidebar-views-items");
|
||||
if (!items) return;
|
||||
// Skip on auth-anon pages (/login, landing) — /api/user-views would 401.
|
||||
if (!document.body.classList.contains("has-sidebar")) return;
|
||||
|
||||
fetch("/api/user-views", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((views: UserViewLite[] | null) => {
|
||||
if (!views) return;
|
||||
const currentPath = window.location.pathname;
|
||||
items.innerHTML = "";
|
||||
for (const view of views) {
|
||||
items.appendChild(renderUserViewItem(view, currentPath));
|
||||
}
|
||||
// After rendering, kick off count refresh for views that opted in.
|
||||
for (const view of views) {
|
||||
if (view.show_count) {
|
||||
void refreshUserViewCount(view);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent — sidebar already shows "+ Neue Sicht" even on failure.
|
||||
});
|
||||
}
|
||||
|
||||
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/views/${encodeURIComponent(view.slug)}`;
|
||||
const active = currentPath === a.pathname;
|
||||
a.className = `sidebar-item sidebar-user-view-item${active ? " active" : ""}`;
|
||||
a.dataset.slug = view.slug;
|
||||
a.dataset.viewId = view.id;
|
||||
|
||||
const iconWrap = document.createElement("span");
|
||||
iconWrap.className = "sidebar-icon";
|
||||
iconWrap.innerHTML = userViewIconSvg(view.icon);
|
||||
a.appendChild(iconWrap);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "sidebar-label";
|
||||
label.textContent = view.name;
|
||||
a.appendChild(label);
|
||||
|
||||
if (view.show_count) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "sidebar-badge sidebar-user-view-badge";
|
||||
badge.id = `sidebar-user-view-badge-${view.id}`;
|
||||
badge.style.display = "none";
|
||||
badge.setAttribute("aria-hidden", "true");
|
||||
a.appendChild(badge);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
async function refreshUserViewCount(view: UserViewLite): Promise<void> {
|
||||
try {
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(view.slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { rows: unknown[] };
|
||||
const badge = document.getElementById(`sidebar-user-view-badge-${view.id}`);
|
||||
if (!badge) return;
|
||||
if (data.rows.length > 0) {
|
||||
badge.textContent = String(data.rows.length);
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
// userViewIconSvg picks an SVG from a small fixed registry. Falls back
|
||||
// to the folder icon for unknown / missing keys. Inline SVGs are used
|
||||
// elsewhere in the sidebar (Sidebar.tsx); we duplicate a minimal subset
|
||||
// here rather than re-exporting because client TS doesn't import from
|
||||
// JSX-emitting modules.
|
||||
function userViewIconSvg(icon?: string): string {
|
||||
switch (icon) {
|
||||
case "clock":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
case "calendar":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
case "bell":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
case "users":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
||||
case "building":
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/></svg>';
|
||||
case "folder":
|
||||
default:
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
// PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side).
|
||||
// PoC scope — see docs/design-paliadin-2026-05-07.md §0.5.
|
||||
const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com";
|
||||
|
||||
// initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht
|
||||
// + Admin) when /api/me confirms the caller is the Paliadin owner. Same
|
||||
// fail-closed display:none pattern as initAdminGroup. Non-owners never
|
||||
// see the entries; the routes themselves return 404 if they navigate
|
||||
// to /paliadin or /admin/paliadin manually anyway.
|
||||
function initPaliadinLinks(): void {
|
||||
const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null;
|
||||
const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null;
|
||||
if (!top && !admin) return;
|
||||
fetch("/api/me", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me: { email?: string } | null) => {
|
||||
if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) {
|
||||
if (top) top.style.display = "";
|
||||
if (admin) admin.style.display = "";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// silent: failing closed is the safe default.
|
||||
});
|
||||
}
|
||||
|
||||
// initAdminGroup reveals the Admin section in the sidebar when the caller's
|
||||
// /api/me lookup confirms global_role='global_admin'. The markup is in the
|
||||
// DOM with display:none for everyone — flipping it on after the fetch lands
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -10,6 +11,25 @@ interface User {
|
||||
job_title?: string | null;
|
||||
}
|
||||
|
||||
interface MembershipEntry {
|
||||
user_id: string;
|
||||
project_ids: string[];
|
||||
lead_project_ids: string[];
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface MeUser {
|
||||
id: string;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
interface DepartmentMember {
|
||||
user_id: string;
|
||||
email: string;
|
||||
@@ -48,9 +68,13 @@ const ROLE_ORDER = [
|
||||
|
||||
let users: User[] = [];
|
||||
let departments: Department[] = [];
|
||||
let memberships: MembershipEntry[] = [];
|
||||
let projectsList: ProjectSummary[] = [];
|
||||
let me: MeUser | null = null;
|
||||
let groupBy: "office" | "department" = "office";
|
||||
let activeOffice = "all";
|
||||
let activeRole = "all";
|
||||
let activeProjectIDs: Set<string> = new Set();
|
||||
let searchQuery = "";
|
||||
|
||||
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
@@ -87,15 +111,26 @@ function initials(name: string): string {
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [usersResp, deptsResp] = await Promise.all([
|
||||
const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([
|
||||
fetch("/api/users"),
|
||||
fetch("/api/partner-units?include=members"),
|
||||
fetch("/api/team/memberships"),
|
||||
fetch("/api/projects"),
|
||||
fetch("/api/me"),
|
||||
]);
|
||||
if (usersResp.ok) users = (await usersResp.json()) as User[];
|
||||
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
|
||||
if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[];
|
||||
if (projectsResp.ok) {
|
||||
const raw = (await projectsResp.json()) as ProjectSummary[];
|
||||
projectsList = raw;
|
||||
}
|
||||
if (meResp.ok) me = (await meResp.json()) as MeUser;
|
||||
buildOfficeFilters();
|
||||
buildRoleFilters();
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
}
|
||||
|
||||
function presentOffices(): string[] {
|
||||
@@ -191,6 +226,176 @@ function userMatchesRole(u: User): boolean {
|
||||
return roleKey(u.job_title) === activeRole.toLowerCase();
|
||||
}
|
||||
|
||||
// userMatchesProject returns true when the project filter is empty or
|
||||
// when the user is a direct member of at least one selected project.
|
||||
// Inherited memberships intentionally don't qualify here — users want
|
||||
// "people I can mail on this matter", which means direct membership.
|
||||
function userMatchesProject(u: User): boolean {
|
||||
if (activeProjectIDs.size === 0) return true;
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
if (!m) return false;
|
||||
for (const pid of m.project_ids) {
|
||||
if (activeProjectIDs.has(pid)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// canBroadcast reports whether the current user is allowed to send a
|
||||
// broadcast given the active project filter. global_admin always wins.
|
||||
// Otherwise the user must be a 'lead' on every project they have
|
||||
// selected (or, when no project is selected, on at least one of their
|
||||
// own projects).
|
||||
function canBroadcast(): boolean {
|
||||
if (!me) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
const myMembership = memberships.find((m) => m.user_id === me?.id);
|
||||
if (!myMembership || !myMembership.lead_project_ids.length) return false;
|
||||
if (activeProjectIDs.size === 0) {
|
||||
// No project filter — allow when caller leads at least one project.
|
||||
// Server-side check still runs per-broadcast so a non-lead can never
|
||||
// actually send.
|
||||
return true;
|
||||
}
|
||||
for (const pid of activeProjectIDs) {
|
||||
if (!myMembership.lead_project_ids.includes(pid)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildProjectFilter() {
|
||||
const container = document.getElementById("team-project-filter");
|
||||
if (!container) return;
|
||||
// Show only projects the caller can see — projectsList already does
|
||||
// that via the visibility-gated /api/projects endpoint.
|
||||
const sortedProjects = [...projectsList].sort((a, b) =>
|
||||
(a.title || "").localeCompare(b.title || ""),
|
||||
);
|
||||
const options = sortedProjects
|
||||
.map(
|
||||
(p) =>
|
||||
`<label class="filter-checkbox"><input type="checkbox" data-project-id="${esc(p.id)}" ${
|
||||
activeProjectIDs.has(p.id) ? "checked" : ""
|
||||
} /> <span>${esc(p.title)}</span></label>`,
|
||||
)
|
||||
.join("");
|
||||
const summary = activeProjectIDs.size === 0
|
||||
? (t("team.filter.project.all") || "Alle Projekte")
|
||||
: `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`;
|
||||
container.innerHTML = `
|
||||
<button type="button" class="filter-pill team-project-trigger" data-project-trigger>
|
||||
<span class="team-project-summary">${esc(t("team.filter.project") || "Projekt")}: ${esc(summary)}</span>
|
||||
</button>
|
||||
<div class="team-project-panel hidden" data-project-panel>
|
||||
<div class="team-project-actions">
|
||||
<button type="button" class="link-button" data-project-clear>${esc(t("team.filter.project.clear") || "Alle abwählen")}</button>
|
||||
</div>
|
||||
<div class="team-project-options">${options}</div>
|
||||
</div>
|
||||
`;
|
||||
const trigger = container.querySelector<HTMLButtonElement>("[data-project-trigger]");
|
||||
const panel = container.querySelector<HTMLDivElement>("[data-project-panel]");
|
||||
trigger?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
panel?.classList.toggle("hidden");
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!container.contains(e.target as Node)) panel?.classList.add("hidden");
|
||||
});
|
||||
container.querySelectorAll<HTMLInputElement>("input[data-project-id]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const pid = cb.dataset.projectId!;
|
||||
if (cb.checked) activeProjectIDs.add(pid);
|
||||
else activeProjectIDs.delete(pid);
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
});
|
||||
});
|
||||
container.querySelector<HTMLButtonElement>("[data-project-clear]")?.addEventListener("click", () => {
|
||||
activeProjectIDs.clear();
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
});
|
||||
}
|
||||
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// displayedRecipients returns the currently visible users as broadcast
|
||||
// recipients. Personal placeholder fields are sourced from each user
|
||||
// (display_name / first_name) and from the membership index when a
|
||||
// project filter is set (role_on_project = the role on the selected
|
||||
// project; falls back to first available role).
|
||||
function displayedRecipients(): BroadcastRecipient[] {
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
return filtered.map((u) => {
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
let role = "";
|
||||
if (m) {
|
||||
if (activeProjectIDs.size > 0) {
|
||||
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
|
||||
if (idx >= 0) role = m.roles[idx];
|
||||
} else if (m.roles.length > 0) {
|
||||
role = m.roles[0];
|
||||
}
|
||||
}
|
||||
return {
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
first_name: firstName(u.display_name),
|
||||
role_on_project: role,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function onBroadcastClick() {
|
||||
const recipients = displayedRecipients();
|
||||
const selectedProjectIDs = Array.from(activeProjectIDs);
|
||||
// When exactly one project is selected we pass it as project_id so
|
||||
// the backend can verify lead-ship on that project. With multi-
|
||||
// select we leave project_id null and rely on global_admin (the
|
||||
// service rejects non-admin senders without a project_id).
|
||||
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
|
||||
const offices = activeOffice === "all" ? [] : [activeOffice];
|
||||
const roles = activeRole === "all" ? [] : [activeRole];
|
||||
openBroadcastModal({
|
||||
recipients,
|
||||
projectID,
|
||||
projectIDs: selectedProjectIDs,
|
||||
offices,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
function memberAsUser(m: DepartmentMember): User | undefined {
|
||||
return users.find((u) => u.id === m.user_id);
|
||||
}
|
||||
@@ -297,8 +502,11 @@ function render() {
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
const count = document.getElementById("team-count")!;
|
||||
|
||||
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesSearch(u));
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
count.textContent = `${filtered.length} / ${users.length}`;
|
||||
updateBroadcastButton();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
|
||||
282
frontend/src/client/views-editor.ts
Normal file
282
frontend/src/client/views-editor.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
defaultFilterSpec,
|
||||
defaultRenderSpec,
|
||||
type DataSource,
|
||||
type FilterSpec,
|
||||
type RenderShape,
|
||||
type RenderSpec,
|
||||
type ScopeMode,
|
||||
type TimeHorizon,
|
||||
type UserView,
|
||||
} from "./views/types";
|
||||
|
||||
// View editor — /views/new (create) and /views/{slug}/edit (modify).
|
||||
// The form has a small fixed set of widgets (no full predicate JSON
|
||||
// editor in v1 — that's a follow-up if power users ask). Saves via
|
||||
// POST/PATCH /api/user-views.
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
interface EditorState {
|
||||
mode: "new" | "edit";
|
||||
// Set in edit mode after the existing view is fetched.
|
||||
existing?: UserView;
|
||||
}
|
||||
|
||||
let state: EditorState = { mode: "new" };
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
state = detectMode();
|
||||
bindShapeToggle();
|
||||
bindForm();
|
||||
bindDelete();
|
||||
if (state.mode === "edit") {
|
||||
void loadExisting();
|
||||
const heading = document.getElementById("editor-heading");
|
||||
if (heading) heading.textContent = t("views.editor.heading.edit");
|
||||
const del = document.getElementById("editor-delete");
|
||||
if (del) del.hidden = false;
|
||||
} else {
|
||||
seedDefaults();
|
||||
}
|
||||
});
|
||||
|
||||
function detectMode(): EditorState {
|
||||
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
|
||||
if (m) return { mode: "edit" };
|
||||
return { mode: "new" };
|
||||
}
|
||||
|
||||
function editSlugFromPath(): string | null {
|
||||
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
async function loadExisting(): Promise<void> {
|
||||
const slug = editSlugFromPath();
|
||||
if (!slug) return;
|
||||
const r = await fetch("/api/user-views", { credentials: "include" });
|
||||
if (!r.ok) {
|
||||
showFeedback("error", t("views.editor.error.load_failed"));
|
||||
return;
|
||||
}
|
||||
const list = (await r.json()) as UserView[];
|
||||
const view = list.find((v) => v.slug === slug);
|
||||
if (!view) {
|
||||
showFeedback("error", t("views.error.not_found"));
|
||||
return;
|
||||
}
|
||||
state.existing = view;
|
||||
populateForm(view);
|
||||
}
|
||||
|
||||
function populateForm(view: UserView): void {
|
||||
setInputValue("editor-name", view.name);
|
||||
setInputValue("editor-slug", view.slug);
|
||||
setSelectValue("editor-icon", view.icon ?? "");
|
||||
setCheckboxValue("editor-show-count", view.show_count);
|
||||
|
||||
for (const src of ["deadline", "appointment", "project_event", "approval_request"] as DataSource[]) {
|
||||
setCheckboxValue(`source-${src}`, view.filter_spec.sources.includes(src), { name: "source", value: src });
|
||||
}
|
||||
|
||||
setSelectValue("editor-scope-mode", view.filter_spec.scope.projects.mode);
|
||||
setCheckboxValue("editor-personal-only", view.filter_spec.scope.personal_only ?? false);
|
||||
|
||||
setSelectValue("editor-time-horizon", view.filter_spec.time.horizon);
|
||||
|
||||
setSelectValue("editor-shape", view.render_spec.shape);
|
||||
setSelectValue("editor-list-density", view.render_spec.list?.density ?? "comfortable");
|
||||
|
||||
// Hide list-density when shape isn't list.
|
||||
toggleListDensityVisibility(view.render_spec.shape);
|
||||
}
|
||||
|
||||
function seedDefaults(): void {
|
||||
// Seed the form with a useful blank-slate spec.
|
||||
const filter = defaultFilterSpec();
|
||||
const render = defaultRenderSpec();
|
||||
for (const src of filter.sources) {
|
||||
setCheckboxValue(`source-${src}`, true, { name: "source", value: src });
|
||||
}
|
||||
setSelectValue("editor-scope-mode", filter.scope.projects.mode);
|
||||
setSelectValue("editor-time-horizon", filter.time.horizon);
|
||||
setSelectValue("editor-shape", render.shape);
|
||||
setSelectValue("editor-list-density", render.list?.density ?? "comfortable");
|
||||
toggleListDensityVisibility(render.shape);
|
||||
}
|
||||
|
||||
function bindShapeToggle(): void {
|
||||
const shapeSelect = document.getElementById("editor-shape") as HTMLSelectElement | null;
|
||||
if (!shapeSelect) return;
|
||||
shapeSelect.addEventListener("change", () => {
|
||||
toggleListDensityVisibility(shapeSelect.value as RenderShape);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleListDensityVisibility(shape: RenderShape): void {
|
||||
const group = document.getElementById("editor-list-density-group");
|
||||
if (!group) return;
|
||||
group.style.display = shape === "list" ? "" : "none";
|
||||
}
|
||||
|
||||
function bindForm(): void {
|
||||
const form = document.getElementById("editor-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = collectForm();
|
||||
if (!payload) return; // collectForm already shows feedback
|
||||
if (state.mode === "edit" && state.existing) {
|
||||
await save("PATCH", `/api/user-views/${state.existing.id}`, payload);
|
||||
} else {
|
||||
await save("POST", `/api/user-views`, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindDelete(): void {
|
||||
const btn = document.getElementById("editor-delete") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!state.existing) return;
|
||||
if (!confirm(t("views.editor.confirm_delete"))) return;
|
||||
const r = await fetch(`/api/user-views/${state.existing.id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!r.ok) {
|
||||
showFeedback("error", t("views.editor.error.delete_failed"));
|
||||
return;
|
||||
}
|
||||
window.location.href = "/views";
|
||||
});
|
||||
}
|
||||
|
||||
interface CreatePayload {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
filter_spec: FilterSpec;
|
||||
render_spec: RenderSpec;
|
||||
show_count: boolean;
|
||||
}
|
||||
|
||||
function collectForm(): CreatePayload | null {
|
||||
const name = getInputValue("editor-name").trim();
|
||||
const slug = getInputValue("editor-slug").trim();
|
||||
const iconRaw = getSelectValue("editor-icon");
|
||||
const icon = iconRaw === "" ? undefined : iconRaw;
|
||||
const showCount = getCheckboxValue("editor-show-count");
|
||||
|
||||
if (!name) {
|
||||
showFeedback("error", t("views.editor.error.name_required"));
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(slug)) {
|
||||
showFeedback("error", t("views.editor.error.slug_format"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const sources: DataSource[] = (["deadline", "appointment", "project_event", "approval_request"] as DataSource[])
|
||||
.filter((s) => getCheckboxValue(`source-${s}`));
|
||||
if (sources.length === 0) {
|
||||
showFeedback("error", t("views.editor.error.sources_required"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const scopeMode = getSelectValue("editor-scope-mode") as ScopeMode;
|
||||
const personalOnly = getCheckboxValue("editor-personal-only");
|
||||
const horizon = getSelectValue("editor-time-horizon") as TimeHorizon;
|
||||
|
||||
const shape = getSelectValue("editor-shape") as RenderShape;
|
||||
const listDensity = getSelectValue("editor-list-density") as "comfortable" | "compact";
|
||||
|
||||
const filter: FilterSpec = {
|
||||
version: 1,
|
||||
sources,
|
||||
scope: {
|
||||
projects: { mode: scopeMode },
|
||||
personal_only: personalOnly,
|
||||
},
|
||||
time: { horizon, field: "auto" },
|
||||
};
|
||||
const render: RenderSpec = {
|
||||
shape,
|
||||
list: shape === "list" ? { density: listDensity, sort: "date_asc" } : undefined,
|
||||
cards: shape === "cards" ? { group_by: "day", sort: "date_asc" } : undefined,
|
||||
calendar: shape === "calendar" ? { default_view: "month" } : undefined,
|
||||
};
|
||||
|
||||
return { slug, name, icon, filter_spec: filter, render_spec: render, show_count: showCount };
|
||||
}
|
||||
|
||||
async function save(method: "POST" | "PATCH", url: string, payload: CreatePayload): Promise<void> {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
showFeedback("error", body.error || `${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
// Saved — go to the saved view.
|
||||
window.location.href = `/views/${encodeURIComponent(payload.slug)}`;
|
||||
}
|
||||
|
||||
// ----- DOM helpers -----
|
||||
|
||||
function getInputValue(id: string): string {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
return el?.value ?? "";
|
||||
}
|
||||
|
||||
function setInputValue(id: string, value: string): void {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (el) el.value = value;
|
||||
}
|
||||
|
||||
function getSelectValue(id: string): string {
|
||||
const el = document.getElementById(id) as HTMLSelectElement | null;
|
||||
return el?.value ?? "";
|
||||
}
|
||||
|
||||
function setSelectValue(id: string, value: string): void {
|
||||
const el = document.getElementById(id) as HTMLSelectElement | null;
|
||||
if (el) el.value = value;
|
||||
}
|
||||
|
||||
function getCheckboxValue(id: string): boolean {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (el) return el.checked;
|
||||
// Fallback: lookup by name+value (for the source-* checkbox group).
|
||||
const m = id.match(/^source-(.+)$/);
|
||||
if (m) {
|
||||
const cb = document.querySelector<HTMLInputElement>(`input[name="source"][value="${m[1]}"]`);
|
||||
return !!cb?.checked;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setCheckboxValue(id: string, value: boolean, fallback?: { name: string; value: string }): void {
|
||||
let el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (!el && fallback) {
|
||||
el = document.querySelector<HTMLInputElement>(`input[name="${fallback.name}"][value="${fallback.value}"]`);
|
||||
}
|
||||
if (el) el.checked = value;
|
||||
}
|
||||
|
||||
function showFeedback(kind: "success" | "error", text: string): void {
|
||||
const el = document.getElementById("editor-feedback");
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
|
||||
el.hidden = false;
|
||||
}
|
||||
251
frontend/src/client/views.ts
Normal file
251
frontend/src/client/views.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { initI18n, t, type I18nKey } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { renderCardsShape } from "./views/shape-cards";
|
||||
import { renderCalendarShape } from "./views/shape-calendar";
|
||||
|
||||
// /views and /views/{slug} client. Loads the saved or system view, runs
|
||||
// it via /api/views/{slug}/run, and dispatches to the matching render-
|
||||
// shape component. Shape-switcher chips toggle the live render without
|
||||
// re-fetching (the rows are already in memory).
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
interface ViewMeta {
|
||||
// For saved views: identifies the row for touch/edit/delete.
|
||||
user_view_id?: string;
|
||||
// Display name + slug.
|
||||
name: string;
|
||||
slug: string;
|
||||
// Filter + render specs (may be overridden by slug detection).
|
||||
filter: FilterSpec;
|
||||
render: RenderSpec;
|
||||
// Whether this is a code-resident SystemView.
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
let currentMeta: ViewMeta | null = null;
|
||||
let currentRows: ViewRunResult | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bindShapeChips();
|
||||
bindToastClose();
|
||||
void hydrate();
|
||||
});
|
||||
|
||||
async function hydrate(): Promise<void> {
|
||||
const slug = pathSlug();
|
||||
if (!slug) {
|
||||
// /views with no slug → empty / onboarding state.
|
||||
const onboarding = document.getElementById("views-onboarding");
|
||||
const loading = document.getElementById("views-loading");
|
||||
if (loading) loading.hidden = true;
|
||||
if (onboarding) onboarding.hidden = false;
|
||||
return;
|
||||
}
|
||||
// Resolve the view: try system first, then user.
|
||||
const meta = await resolveMeta(slug);
|
||||
if (!meta) {
|
||||
showError(t("views.error.not_found"));
|
||||
return;
|
||||
}
|
||||
currentMeta = meta;
|
||||
document.title = `${meta.name} — Paliad`;
|
||||
updateHeader(meta);
|
||||
await runAndRender(meta);
|
||||
if (meta.user_view_id) {
|
||||
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMeta(slug: string): Promise<ViewMeta | null> {
|
||||
// Try the system view list first — cheap, code-resident.
|
||||
try {
|
||||
const r = await fetch("/api/views/system", { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const list = (await r.json()) as Array<{ Slug: string; Name: string; Filter: FilterSpec; Render: RenderSpec }>;
|
||||
const sys = list.find((sv) => sv.Slug === slug);
|
||||
if (sys) {
|
||||
return { name: sys.Name, slug: sys.Slug, filter: sys.Filter, render: sys.Render, is_system: true };
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// fall through to user lookup
|
||||
}
|
||||
// Try a saved user view.
|
||||
try {
|
||||
const r = await fetch("/api/user-views", { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const list = (await r.json()) as UserView[];
|
||||
const v = list.find((uv) => uv.slug === slug);
|
||||
if (v) {
|
||||
return {
|
||||
user_view_id: v.id,
|
||||
name: v.name,
|
||||
slug: v.slug,
|
||||
filter: v.filter_spec,
|
||||
render: v.render_spec,
|
||||
is_system: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runAndRender(meta: ViewMeta): Promise<void> {
|
||||
const loading = document.getElementById("views-loading");
|
||||
const empty = document.getElementById("views-empty");
|
||||
const errorEl = document.getElementById("views-error");
|
||||
const toolbar = document.getElementById("views-toolbar");
|
||||
if (loading) loading.hidden = false;
|
||||
if (empty) empty.hidden = true;
|
||||
if (errorEl) errorEl.hidden = true;
|
||||
if (toolbar) toolbar.hidden = false;
|
||||
|
||||
let result: ViewRunResult;
|
||||
try {
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
showError(`${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
result = (await r.json()) as ViewRunResult;
|
||||
} catch (e) {
|
||||
showError(t("views.error.network"));
|
||||
return;
|
||||
}
|
||||
if (loading) loading.hidden = true;
|
||||
|
||||
currentRows = result;
|
||||
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
|
||||
showInaccessibleToast(result.inaccessible_project_ids.length);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
const hint = document.getElementById("views-empty-hint");
|
||||
if (hint) hint.textContent = filterSummary(meta.filter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveShape(meta.render.shape);
|
||||
renderShape(meta.render.shape, meta.render, result.rows);
|
||||
}
|
||||
|
||||
function setActiveShape(shape: RenderShape): void {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
|
||||
const el = document.getElementById(host);
|
||||
if (el) el.hidden = !host.endsWith("-" + shape);
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shape === shape);
|
||||
});
|
||||
}
|
||||
|
||||
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
|
||||
const host = document.getElementById(`views-shape-${shape}`);
|
||||
if (!host) return;
|
||||
switch (shape) {
|
||||
case "list":
|
||||
renderListShape(host, rows, render);
|
||||
break;
|
||||
case "cards":
|
||||
renderCardsShape(host, rows, render);
|
||||
break;
|
||||
case "calendar":
|
||||
renderCalendarShape(host, rows, render);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function bindShapeChips(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const shape = (btn.dataset.shape ?? "list") as RenderShape;
|
||||
if (!currentMeta || !currentRows) return;
|
||||
// Override the shape transiently — doesn't mutate the saved spec.
|
||||
const overrideRender = { ...currentMeta.render, shape };
|
||||
setActiveShape(shape);
|
||||
renderShape(shape, overrideRender, currentRows.rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeader(meta: ViewMeta): void {
|
||||
const heading = document.getElementById("views-heading");
|
||||
if (heading) heading.textContent = meta.name;
|
||||
const subtitle = document.getElementById("views-subtitle");
|
||||
if (subtitle) subtitle.textContent = filterSummary(meta.filter);
|
||||
const actions = document.getElementById("views-header-actions");
|
||||
if (actions) {
|
||||
actions.innerHTML = "";
|
||||
if (!meta.is_system && meta.user_view_id) {
|
||||
const editLink = document.createElement("a");
|
||||
editLink.href = `/views/${encodeURIComponent(meta.slug)}/edit`;
|
||||
editLink.className = "btn-secondary btn-small";
|
||||
editLink.textContent = t("views.action.edit");
|
||||
actions.appendChild(editLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterSummary(filter: FilterSpec): string {
|
||||
const parts: string[] = [];
|
||||
// Sources
|
||||
parts.push(filter.sources.map((s) => t(("views.source." + s) as I18nKey)).join(" + "));
|
||||
// Time
|
||||
parts.push(t(("views.horizon." + filter.time.horizon) as I18nKey));
|
||||
// Scope
|
||||
if (filter.scope.personal_only) {
|
||||
parts.push(t("views.scope.personal_only"));
|
||||
} else if (filter.scope.projects.mode !== "all_visible") {
|
||||
parts.push(t(("views.scope." + filter.scope.projects.mode) as I18nKey));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
const loading = document.getElementById("views-loading");
|
||||
const errorEl = document.getElementById("views-error");
|
||||
const msg = document.getElementById("views-error-message");
|
||||
if (loading) loading.hidden = true;
|
||||
if (errorEl) errorEl.hidden = false;
|
||||
if (msg) msg.textContent = message;
|
||||
}
|
||||
|
||||
function showInaccessibleToast(count: number): void {
|
||||
const toast = document.getElementById("views-toast");
|
||||
const text = document.getElementById("views-toast-text");
|
||||
if (!toast || !text) return;
|
||||
text.textContent = count === 1
|
||||
? t("views.toast.inaccessible_one")
|
||||
: t("views.toast.inaccessible_n").replace("{n}", String(count));
|
||||
toast.hidden = false;
|
||||
}
|
||||
|
||||
function bindToastClose(): void {
|
||||
const close = document.getElementById("views-toast-close");
|
||||
const toast = document.getElementById("views-toast");
|
||||
if (!close || !toast) return;
|
||||
close.addEventListener("click", () => { toast.hidden = true; });
|
||||
}
|
||||
|
||||
function pathSlug(): string | null {
|
||||
const m = window.location.pathname.match(/^\/views\/([^\/]+)$/);
|
||||
if (!m) return null;
|
||||
return decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
function fireAndForget(url: string, method: string): void {
|
||||
fetch(url, { method, credentials: "include" }).catch(() => { /* noop */ });
|
||||
}
|
||||
109
frontend/src/client/views/format.test.ts
Normal file
109
frontend/src/client/views/format.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
formatDate,
|
||||
formatRelative,
|
||||
formatRowTime,
|
||||
formatTime,
|
||||
isDateOnly,
|
||||
parseDateOnly,
|
||||
} from "./format";
|
||||
import type { ViewRow } from "./types";
|
||||
|
||||
// Regression tests for t-paliad-153: deadline due_date renders as 02:00 in
|
||||
// CEST. The substrate marshals deadline.due_date as "YYYY-MM-DDT00:00:00Z";
|
||||
// the formatters must treat that as a calendar day with no time component.
|
||||
|
||||
const stubRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
|
||||
kind: "deadline",
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
title: "Call me",
|
||||
event_date: "2026-05-08T00:00:00Z",
|
||||
detail: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isDateOnly / parseDateOnly", () => {
|
||||
test("recognises YYYY-MM-DD", () => {
|
||||
expect(isDateOnly("2026-05-08")).toBe(true);
|
||||
expect(parseDateOnly("2026-05-08")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("recognises the substrate's UTC-midnight serialisation", () => {
|
||||
expect(isDateOnly("2026-05-08T00:00:00Z")).toBe(true);
|
||||
expect(parseDateOnly("2026-05-08T00:00:00Z")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("rejects timestamps with a real time component", () => {
|
||||
expect(isDateOnly("2026-05-08T14:30:00Z")).toBe(false);
|
||||
expect(parseDateOnly("2026-05-08T14:30:00Z")).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects garbage", () => {
|
||||
expect(isDateOnly("not-a-date")).toBe(false);
|
||||
expect(parseDateOnly("not-a-date")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
test("returns empty string for date-only inputs (no phantom 02:00)", () => {
|
||||
expect(formatTime("2026-05-08T00:00:00Z")).toBe("");
|
||||
expect(formatTime("2026-05-08")).toBe("");
|
||||
});
|
||||
|
||||
test("renders HH:MM for real timestamps", () => {
|
||||
expect(formatTime("2026-05-08T14:30:00Z")).toMatch(/\d{2}:\d{2}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("date-only input formats the source day in any timezone", () => {
|
||||
// Whatever locale getLang() resolves to, the day portion must be 08.
|
||||
const out = formatDate("2026-05-08T00:00:00Z");
|
||||
expect(out).toContain("08");
|
||||
expect(out).toContain("2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRowTime", () => {
|
||||
test("deadline + dateAvailable=true returns empty (heading shows the day)", () => {
|
||||
expect(formatRowTime(stubRow(), { dateAvailable: true })).toBe("");
|
||||
});
|
||||
|
||||
test("deadline + dateAvailable=false falls back to the date", () => {
|
||||
expect(formatRowTime(stubRow(), { dateAvailable: false })).toContain("2026");
|
||||
});
|
||||
|
||||
test("appointment with a real start_at still renders HH:MM", () => {
|
||||
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T14:30:00Z" });
|
||||
expect(formatRowTime(row, { dateAvailable: true })).toMatch(/\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test("appointment with date-only event_date does not leak phantom time", () => {
|
||||
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T00:00:00Z" });
|
||||
// Belt-and-braces: even if a stray date-only value shows up under a
|
||||
// non-deadline kind, the helper detects it and returns "" instead of
|
||||
// "02:00" / "01:00" / etc.
|
||||
expect(formatRowTime(row, { dateAvailable: true })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelative", () => {
|
||||
test("deadline kind reduces to day precision", () => {
|
||||
const today = new Date();
|
||||
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T00:00:00Z`;
|
||||
const out = formatRelative(todayISO, "deadline");
|
||||
expect(out.toLowerCase()).toMatch(/heute|today/);
|
||||
});
|
||||
|
||||
test("date-only iso reduces to day precision even without an explicit kind", () => {
|
||||
const today = new Date();
|
||||
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const out = formatRelative(todayISO);
|
||||
expect(out.toLowerCase()).toMatch(/heute|today/);
|
||||
});
|
||||
|
||||
test("real timestamp keeps moment-precision relative", () => {
|
||||
const inAnHour = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
expect(formatRelative(inAnHour, "appointment")).toMatch(/\d/);
|
||||
});
|
||||
});
|
||||
122
frontend/src/client/views/format.ts
Normal file
122
frontend/src/client/views/format.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getLang } from "../i18n";
|
||||
import type { ViewRow } from "./types";
|
||||
|
||||
// Shared date/time formatters for the views shapes (list / cards / calendar).
|
||||
//
|
||||
// The substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so
|
||||
// the JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time
|
||||
// component. Feeding that into new Date() + toLocaleTimeString() in a
|
||||
// non-UTC browser produces "02:00" (CEST), "01:00" (CET), "20:00" the day
|
||||
// before (EST), and so on — a phantom hour that the source data never had.
|
||||
//
|
||||
// The fix is to recognise the date-only shape and either render the date
|
||||
// (formatted in UTC so the day matches the source day everywhere) or render
|
||||
// nothing in the time slot. The kind-aware helpers below thread that
|
||||
// distinction through the shapes; see t-paliad-153.
|
||||
|
||||
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T00:00:00(?:\.0+)?Z)?$/;
|
||||
|
||||
export function isDateOnly(iso: string): boolean {
|
||||
return typeof iso === "string" && DATE_ONLY_RE.test(iso);
|
||||
}
|
||||
|
||||
export function parseDateOnly(iso: string): Date | null {
|
||||
if (typeof iso !== "string") return null;
|
||||
const m = iso.match(DATE_ONLY_RE);
|
||||
if (!m) return null;
|
||||
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function locale(): string {
|
||||
return getLang() === "de" ? "de-DE" : "en-GB";
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
const dateOnly = parseDateOnly(iso);
|
||||
if (dateOnly) {
|
||||
return dateOnly.toLocaleDateString(locale(), {
|
||||
day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(locale(), {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLongDate(iso: string): string {
|
||||
const dateOnly = parseDateOnly(iso);
|
||||
if (dateOnly) {
|
||||
return dateOnly.toLocaleDateString(locale(), {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(locale(), {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// formatTime returns "" for date-only inputs — they have no real time and
|
||||
// rendering them as HH:MM leaks the local UTC offset.
|
||||
export function formatTime(iso: string): string {
|
||||
if (isDateOnly(iso)) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleTimeString(locale(), { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// formatRowTime: the time-slot helper used by shape-cards. When the
|
||||
// surrounding shape already shows the date (e.g. day-grouped headings),
|
||||
// deadlines render nothing — the date is implicit. Otherwise the deadline
|
||||
// row falls back to its date so the user still knows when it's due.
|
||||
export function formatRowTime(row: ViewRow, opts: { dateAvailable: boolean }): string {
|
||||
if (row.kind === "deadline" || isDateOnly(row.event_date)) {
|
||||
return opts.dateAvailable ? "" : formatDate(row.event_date);
|
||||
}
|
||||
return formatTime(row.event_date);
|
||||
}
|
||||
|
||||
// formatRelative: deadlines reduce to day precision so a Frist due
|
||||
// "tomorrow" never shows up as "in 2h" because of the UTC offset.
|
||||
export function formatRelative(iso: string, kind?: ViewRow["kind"]): string {
|
||||
if (kind === "deadline" || isDateOnly(iso)) return formatDayRelative(iso);
|
||||
return formatMomentRelative(iso);
|
||||
}
|
||||
|
||||
function formatDayRelative(iso: string): string {
|
||||
const due = parseDateOnly(iso);
|
||||
if (!due) return formatMomentRelative(iso);
|
||||
const today = new Date();
|
||||
const todayUTC = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const diffDays = Math.round((due.getTime() - todayUTC) / 86400000);
|
||||
const lang = getLang();
|
||||
if (diffDays < 0) {
|
||||
const n = Math.abs(diffDays);
|
||||
return lang === "de"
|
||||
? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
|
||||
: (n === 1 ? "1 day ago" : `${n} days ago`);
|
||||
}
|
||||
if (diffDays === 0) return lang === "de" ? "heute" : "today";
|
||||
if (diffDays === 1) return lang === "de" ? "morgen" : "tomorrow";
|
||||
return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
|
||||
}
|
||||
|
||||
function formatMomentRelative(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = t0 - Date.now();
|
||||
const past = diffMs < 0;
|
||||
const sec = Math.abs(Math.floor(diffMs / 1000));
|
||||
const lang = getLang();
|
||||
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
|
||||
const day = Math.floor(hr / 24);
|
||||
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
|
||||
}
|
||||
129
frontend/src/client/views/shape-calendar.ts
Normal file
129
frontend/src/client/views/shape-calendar.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { t, type I18nKey, getLang } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-calendar: month grid. Toggleable to week-view via per-shape
|
||||
// config. Mirrors the look of /events?view=calendar but generic across
|
||||
// sources.
|
||||
|
||||
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const cfg = render.calendar ?? {};
|
||||
const view = cfg.default_view ?? "month";
|
||||
|
||||
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
|
||||
// screens). Documented in design §9 trade-off 8.
|
||||
if (window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
|
||||
const monthRef = pickMonthAnchor(rows);
|
||||
wrap.appendChild(renderMonth(monthRef, rows));
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
// Weekday headers (Mon-Sun, ISO week).
|
||||
const weekdayBar = document.createElement("div");
|
||||
weekdayBar.className = "views-calendar-weekdays";
|
||||
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
weekdayBar.appendChild(cell);
|
||||
}
|
||||
wrap.appendChild(weekdayBar);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
// Pad start with prev-month spillover.
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
// Bucket rows by ISO date (yyyy-mm-dd).
|
||||
const byDate = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = byDate.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else byDate.set(key, [row]);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
const dayLabel = document.createElement("div");
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(day);
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, 3);
|
||||
for (const row of visible) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
li.textContent = row.title;
|
||||
li.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
ul.appendChild(li);
|
||||
}
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
more.className = "views-calendar-pill views-calendar-pill--more";
|
||||
more.textContent = `+${dayRows.length - visible.length}`;
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function pickMonthAnchor(rows: ViewRow[]): Date {
|
||||
// Anchor on the first row's month, or "this month" if empty.
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
128
frontend/src/client/views/shape-cards.ts
Normal file
128
frontend/src/client/views/shape-cards.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { t, type I18nKey, getLang } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
import { formatLongDate, formatRowTime, parseDateOnly } from "./format";
|
||||
|
||||
// shape-cards: day-grouped chronological cards. Same layout style as the
|
||||
// existing /agenda timeline; works for any source mix.
|
||||
|
||||
export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const cfg = render.cards ?? {};
|
||||
const groupBy = cfg.group_by ?? "day";
|
||||
const sort = cfg.sort ?? "date_asc";
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aT = sortKey(a.event_date);
|
||||
const bT = sortKey(b.event_date);
|
||||
return sort === "date_asc" ? aT - bT : bT - aT;
|
||||
});
|
||||
|
||||
if (groupBy === "none") {
|
||||
host.appendChild(renderCardList(sorted, "none"));
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = groupRows(sorted, groupBy);
|
||||
for (const [key, items] of groups) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "views-cards-day";
|
||||
const heading = document.createElement("h2");
|
||||
heading.className = "views-cards-day-heading";
|
||||
heading.textContent = key;
|
||||
section.appendChild(heading);
|
||||
section.appendChild(renderCardList(items, groupBy));
|
||||
host.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCardList(rows: ViewRow[], groupBy: "day" | "week" | "none"): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-cards-list";
|
||||
// The day-grouped heading already shows the date — only that mode lets the
|
||||
// per-row time slot stay blank for date-only sources.
|
||||
const dateAvailable = groupBy === "day";
|
||||
for (const row of rows) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `views-card views-card--${row.kind}`;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-card-head";
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "views-card-kind";
|
||||
kind.textContent = t(("views.kind." + row.kind) as I18nKey);
|
||||
head.appendChild(kind);
|
||||
const title = document.createElement("h3");
|
||||
title.className = "views-card-title";
|
||||
title.textContent = row.title;
|
||||
head.appendChild(title);
|
||||
li.appendChild(head);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "views-card-meta";
|
||||
const timeText = formatRowTime(row, { dateAvailable });
|
||||
if (timeText) {
|
||||
const time = document.createElement("span");
|
||||
time.textContent = timeText;
|
||||
meta.appendChild(time);
|
||||
}
|
||||
if (row.project_title) {
|
||||
const proj = document.createElement("span");
|
||||
proj.className = "views-card-project";
|
||||
proj.textContent = row.project_title;
|
||||
meta.appendChild(proj);
|
||||
}
|
||||
if (row.actor_name) {
|
||||
const actor = document.createElement("span");
|
||||
actor.className = "views-card-actor";
|
||||
actor.textContent = row.actor_name;
|
||||
meta.appendChild(actor);
|
||||
}
|
||||
li.appendChild(meta);
|
||||
|
||||
if (row.subtitle) {
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "views-card-subtitle";
|
||||
sub.textContent = row.subtitle;
|
||||
li.appendChild(sub);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, ViewRow[]]> {
|
||||
const map = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = bucketKey(row.event_date, groupBy);
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else map.set(key, [row]);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}
|
||||
|
||||
function bucketKey(iso: string, groupBy: "day" | "week"): string {
|
||||
// Date-only inputs (deadlines) are anchored to UTC midnight so the bucket
|
||||
// matches the source day in every timezone — otherwise a UTC-X user would
|
||||
// see deadlines slip into the previous day.
|
||||
const dateOnly = parseDateOnly(iso);
|
||||
const d = dateOnly ?? new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (groupBy === "week") {
|
||||
const monday = new Date(d);
|
||||
const day = monday.getDay() || 7; // Sunday=0 → 7
|
||||
monday.setDate(monday.getDate() - day + 1);
|
||||
const yearStart = new Date(Date.UTC(monday.getFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
|
||||
return `KW ${weekNo}, ${monday.getFullYear()}`;
|
||||
}
|
||||
if (dateOnly) return formatLongDate(iso);
|
||||
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
function sortKey(iso: string): number {
|
||||
const dateOnly = parseDateOnly(iso);
|
||||
if (dateOnly) return dateOnly.getTime();
|
||||
return Date.parse(iso);
|
||||
}
|
||||
340
frontend/src/client/views/shape-list.ts
Normal file
340
frontend/src/client/views/shape-list.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
|
||||
import { formatDate, formatRelative, parseDateOnly } from "./format";
|
||||
|
||||
// shape-list: renders ViewRows as a table (density=comfortable) or a
|
||||
// compact one-line stream (density=compact). The "activity feed" look
|
||||
// is just density=compact + actor/time columns — see Q4 lock-in
|
||||
// 2026-05-07 (3 shapes; no separate "activity").
|
||||
//
|
||||
// Row interaction is controlled by render.list.row_action
|
||||
// (t-paliad-163 schema bump). Default "navigate" keeps every existing
|
||||
// caller's contract — clicking a row goes to the per-kind detail
|
||||
// page. "approve" produces the approval-list layout for /inbox.
|
||||
// "complete_toggle" is wired in Phase 3 (/events). "none" suppresses
|
||||
// any row interaction (audit views).
|
||||
|
||||
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const list = render.list ?? {};
|
||||
const density = list.density ?? "comfortable";
|
||||
const sort = list.sort ?? "date_asc";
|
||||
const rowAction: ListRowAction = list.row_action ?? "navigate";
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aT = sortKey(a.event_date);
|
||||
const bT = sortKey(b.event_date);
|
||||
return sort === "date_asc" ? aT - bT : bT - aT;
|
||||
});
|
||||
|
||||
if (rowAction === "approve") {
|
||||
host.appendChild(renderApprovalList(sorted));
|
||||
return;
|
||||
}
|
||||
|
||||
if (density === "compact") {
|
||||
host.appendChild(renderCompact(sorted));
|
||||
} else {
|
||||
host.appendChild(renderTable(sorted, list.columns ?? defaultColumns(rows)));
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompact(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-list views-list--compact";
|
||||
for (const row of rows) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `views-list-row views-list-row--${row.kind}`;
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "views-list-time";
|
||||
time.textContent = formatRelative(row.event_date, row.kind);
|
||||
li.appendChild(time);
|
||||
|
||||
const kindIcon = document.createElement("span");
|
||||
kindIcon.className = "views-list-kind";
|
||||
kindIcon.textContent = kindLabel(row.kind);
|
||||
li.appendChild(kindIcon);
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-list-title";
|
||||
title.textContent = row.title;
|
||||
li.appendChild(title);
|
||||
|
||||
if (row.project_title) {
|
||||
const proj = document.createElement("span");
|
||||
proj.className = "views-list-project";
|
||||
proj.textContent = row.project_title;
|
||||
li.appendChild(proj);
|
||||
}
|
||||
|
||||
if (row.actor_name) {
|
||||
const actor = document.createElement("span");
|
||||
actor.className = "views-list-actor";
|
||||
actor.textContent = row.actor_name;
|
||||
li.appendChild(actor);
|
||||
}
|
||||
|
||||
if (row.subtitle) {
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "views-list-subtitle";
|
||||
sub.textContent = row.subtitle;
|
||||
li.appendChild(sub);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function renderTable(rows: ViewRow[], columns: string[]): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "entity-table-wrap";
|
||||
const table = document.createElement("table");
|
||||
table.className = "entity-table views-list views-list--table entity-table--readonly";
|
||||
const thead = document.createElement("thead");
|
||||
const trHead = document.createElement("tr");
|
||||
for (const col of columns) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = t(("views.col." + col) as I18nKey);
|
||||
trHead.appendChild(th);
|
||||
}
|
||||
thead.appendChild(trHead);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = `views-table-row views-table-row--${row.kind}`;
|
||||
for (const col of columns) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = formatColumn(row, col);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
wrap.appendChild(table);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function defaultColumns(rows: ViewRow[]): string[] {
|
||||
// Pick a sensible default column set from the kinds present in the
|
||||
// result. Keeps the UI honest when a user lands on a saved view that
|
||||
// has no explicit list.columns.
|
||||
const kinds = new Set(rows.map((r) => r.kind));
|
||||
if (kinds.has("project_event") || kinds.has("approval_request")) {
|
||||
return ["time", "actor", "title", "project"];
|
||||
}
|
||||
if (kinds.has("appointment")) {
|
||||
return ["date", "title", "project", "location"];
|
||||
}
|
||||
return ["date", "title", "project", "status"];
|
||||
}
|
||||
|
||||
function formatColumn(row: ViewRow, col: string): string {
|
||||
switch (col) {
|
||||
case "date":
|
||||
return formatDate(row.event_date);
|
||||
case "time":
|
||||
return formatRelative(row.event_date, row.kind);
|
||||
case "title":
|
||||
return row.title;
|
||||
case "project":
|
||||
return row.project_title ?? "—";
|
||||
case "actor":
|
||||
return row.actor_name ?? "—";
|
||||
case "status": {
|
||||
const s = (row.detail.status as string | undefined) ?? "";
|
||||
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
||||
}
|
||||
case "rule":
|
||||
return (row.detail.rule_code as string | undefined) ?? "—";
|
||||
case "event_type":
|
||||
return (row.detail.event_type as string | undefined) ?? "—";
|
||||
case "location":
|
||||
return (row.detail.location as string | undefined) ?? "—";
|
||||
case "appointment_type":
|
||||
return (row.detail.appointment_type as string | undefined) ?? "—";
|
||||
case "approval_status":
|
||||
return (row.detail.approval_status as string | undefined) ?? "—";
|
||||
case "decided_by":
|
||||
return (row.detail.decider_name as string | undefined) ?? "—";
|
||||
case "kind":
|
||||
return kindLabel(row.kind);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return t(("views.kind." + kind) as I18nKey);
|
||||
}
|
||||
|
||||
function sortKey(iso: string): number {
|
||||
const dateOnly = parseDateOnly(iso);
|
||||
if (dateOnly) return dateOnly.getTime();
|
||||
return Date.parse(iso);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// row_action = "approve" — approval inbox layout
|
||||
//
|
||||
// Stamps the markup the /inbox surface needs (data attrs + classes);
|
||||
// the surface (client/inbox.ts) wires the action handlers in onResult.
|
||||
// This keeps shape-list independent of any specific surface's wiring.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface ApprovalDetail {
|
||||
status?: string;
|
||||
lifecycle_event?: string;
|
||||
entity_type?: string;
|
||||
entity_title?: string;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role?: string;
|
||||
requester_name?: string;
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
}
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
for (const row of rows) {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// The bar's approval_viewer_role distinguishes which actions are
|
||||
// appropriate. The surface inspects the active role and decides
|
||||
// which buttons to keep — but for default rendering we stamp all
|
||||
// three with role-class hints and let the surface filter.
|
||||
actions.appendChild(actionBtn("approve"));
|
||||
actions.appendChild(actionBtn("reject"));
|
||||
actions.appendChild(actionBtn("revoke"));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (detail.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) => v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Suppress unused warning for tDyn — kept available for future axes.
|
||||
void tDyn;
|
||||
162
frontend/src/client/views/types.ts
Normal file
162
frontend/src/client/views/types.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Shared TypeScript types for the Custom Views frontend.
|
||||
//
|
||||
// These mirror the Go shapes in internal/services/{filter_spec,
|
||||
// render_spec,view_service,user_view_service}.go. Keep field names + enum
|
||||
// values in sync — the substrate's validator will reject anything else.
|
||||
|
||||
export type DataSource = "deadline" | "appointment" | "project_event" | "approval_request";
|
||||
|
||||
export type ScopeMode = "all_visible" | "my_subtree" | "explicit";
|
||||
|
||||
export interface ScopeProjects {
|
||||
mode: ScopeMode;
|
||||
ids?: string[];
|
||||
}
|
||||
|
||||
export interface ScopeSpec {
|
||||
projects: ScopeProjects;
|
||||
personal_only?: boolean;
|
||||
}
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_30d" | "past_90d"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
|
||||
export interface TimeSpec {
|
||||
horizon: TimeHorizon;
|
||||
field?: TimeField;
|
||||
from?: string; // ISO 8601
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface DeadlinePredicates {
|
||||
status?: string[];
|
||||
approval_status?: string[];
|
||||
event_types?: string[];
|
||||
include_untyped?: boolean;
|
||||
}
|
||||
|
||||
export interface AppointmentPredicates {
|
||||
approval_status?: string[];
|
||||
appointment_types?: string[];
|
||||
}
|
||||
|
||||
export interface ProjectEventPredicates {
|
||||
event_types?: string[];
|
||||
}
|
||||
|
||||
export interface ApprovalRequestPredicates {
|
||||
viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
|
||||
status?: string[];
|
||||
entity_types?: string[];
|
||||
}
|
||||
|
||||
export interface Predicates {
|
||||
deadline?: DeadlinePredicates;
|
||||
appointment?: AppointmentPredicates;
|
||||
project_event?: ProjectEventPredicates;
|
||||
approval_request?: ApprovalRequestPredicates;
|
||||
}
|
||||
|
||||
export interface FilterSpec {
|
||||
version: number;
|
||||
sources: DataSource[];
|
||||
scope: ScopeSpec;
|
||||
time: TimeSpec;
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
}
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar";
|
||||
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
|
||||
|
||||
export interface ListConfig {
|
||||
columns?: string[];
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
row_action?: ListRowAction;
|
||||
}
|
||||
|
||||
export interface CardsConfig {
|
||||
group_by?: "day" | "week" | "none";
|
||||
sort?: "date_asc" | "date_desc";
|
||||
show_empty_days?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarConfig {
|
||||
default_view?: "month" | "week";
|
||||
show_weekends?: boolean;
|
||||
}
|
||||
|
||||
export interface RenderSpec {
|
||||
shape: RenderShape;
|
||||
list?: ListConfig;
|
||||
cards?: CardsConfig;
|
||||
calendar?: CalendarConfig;
|
||||
}
|
||||
|
||||
// ViewRow — the discriminated row shape from ViewService.RunSpec.
|
||||
export interface ViewRow {
|
||||
kind: DataSource;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
event_date: string;
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
project_type?: string;
|
||||
actor_id?: string;
|
||||
actor_name?: string;
|
||||
detail: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ViewRunResult {
|
||||
rows: ViewRow[];
|
||||
inaccessible_project_ids?: string[];
|
||||
}
|
||||
|
||||
// UserView — the persisted shape from /api/user-views.
|
||||
export interface UserView {
|
||||
id: string;
|
||||
user_id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
filter_spec: FilterSpec;
|
||||
render_spec: RenderSpec;
|
||||
sort_order: number;
|
||||
show_count: boolean;
|
||||
last_used_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// SystemView — code-resident definition from /api/views/system.
|
||||
export interface SystemView {
|
||||
Slug: string;
|
||||
Name: string;
|
||||
Filter: FilterSpec;
|
||||
Render: RenderSpec;
|
||||
}
|
||||
|
||||
export const SPEC_VERSION = 1;
|
||||
|
||||
export function defaultFilterSpec(): FilterSpec {
|
||||
return {
|
||||
version: SPEC_VERSION,
|
||||
sources: ["deadline", "appointment"],
|
||||
scope: { projects: { mode: "all_visible" } },
|
||||
time: { horizon: "next_30d", field: "auto" },
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultRenderSpec(): RenderSpec {
|
||||
return {
|
||||
shape: "list",
|
||||
list: { sort: "date_asc", density: "comfortable" },
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { h, Fragment } from "../jsx";
|
||||
|
||||
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
|
||||
const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
|
||||
const ICON_PLUS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
|
||||
const ICON_DEADLINE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
@@ -39,7 +38,10 @@ export function BottomNav({ currentPath }: BottomNavProps): string {
|
||||
<span className="bottom-nav-label" data-i18n="bottomnav.add">Anlegen</span>
|
||||
</button>
|
||||
|
||||
{slot("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath, "bottom-nav-agenda-badge")}
|
||||
{/* t-paliad-162 — Agenda lives inline on the dashboard now; the
|
||||
mobile slot points at Fristen so today's-deadline access stays
|
||||
one tap away from the bottom rail. */}
|
||||
{slot("/events?type=deadline", ICON_DEADLINE, "nav.fristen", "Fristen", currentPath, "bottom-nav-agenda-badge")}
|
||||
|
||||
<button type="button" className="bottom-nav-slot" id="bottom-nav-menu" aria-label="Menü">
|
||||
<span className="bottom-nav-icon" dangerouslySetInnerHTML={{ __html: ICON_MENU }} />
|
||||
|
||||
177
frontend/src/components/PaliadinWidget.tsx
Normal file
177
frontend/src/components/PaliadinWidget.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { h, Fragment } from "../jsx";
|
||||
|
||||
// PaliadinWidget — inline floating-button + slide-out drawer for the
|
||||
// Paliad assistant (t-paliad-161).
|
||||
//
|
||||
// Rendered on every authenticated page near </body>. Hidden by default
|
||||
// (display:none) and revealed by client/paliadin-widget.ts after a
|
||||
// /api/me call confirms the caller is the Paliadin owner — same fail-
|
||||
// closed pattern as the sidebar /paliadin link.
|
||||
//
|
||||
// Visibility is also gated on the current pathname: hidden on /paliadin
|
||||
// (the standalone page IS the assistant), /login, and /onboarding.
|
||||
//
|
||||
// Trigger: click the floating ✨ button or press Cmd+J / Ctrl+J. (Cmd+K
|
||||
// is reserved for the global search palette in client/search.ts.)
|
||||
|
||||
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
|
||||
const ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
const ICON_RESET = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 4 3 10 9 10"/></svg>';
|
||||
const ICON_FULLSCREEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l-4 4"/><path d="M21 3l-4 4"/><polyline points="14 3 21 3 21 10"/><polyline points="10 21 3 21 3 14"/></svg>';
|
||||
const ICON_SEND = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||
|
||||
export function PaliadinWidget(): string {
|
||||
return (
|
||||
<Fragment>
|
||||
{/*
|
||||
Floating trigger button. Hidden by default (display:none); revealed
|
||||
by client/paliadin-widget.ts after /api/me confirms the caller is
|
||||
the Paliadin owner AND the route is one where the widget should
|
||||
show. The widget hides itself again on /paliadin, /login,
|
||||
/onboarding via the same predicate.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
id="paliadin-widget-trigger"
|
||||
className="paliadin-widget-trigger"
|
||||
style="display:none"
|
||||
aria-label="Paliadin"
|
||||
title="Paliadin (Cmd+J)"
|
||||
data-i18n-title="paliadin.widget.trigger"
|
||||
>
|
||||
<span className="paliadin-widget-trigger-glyph"
|
||||
dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Backdrop scrim — receives clicks to close. Pointer-events guard
|
||||
on display:none (CSS) until the drawer opens.
|
||||
*/}
|
||||
<div
|
||||
id="paliadin-widget-scrim"
|
||||
className="paliadin-widget-scrim"
|
||||
aria-hidden="true"
|
||||
style="display:none"
|
||||
/>
|
||||
|
||||
{/*
|
||||
Slide-out drawer. role="dialog" + aria-modal so screen readers
|
||||
announce it as a modal panel; aria-labelledby points at the
|
||||
header h2.
|
||||
*/}
|
||||
<aside
|
||||
id="paliadin-widget-drawer"
|
||||
className="paliadin-widget-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="paliadin-widget-title"
|
||||
aria-hidden="true"
|
||||
style="display:none"
|
||||
data-open="false"
|
||||
>
|
||||
<header className="paliadin-widget-header">
|
||||
<h2 id="paliadin-widget-title" className="paliadin-widget-title">
|
||||
<span className="paliadin-widget-title-glyph"
|
||||
dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span data-i18n="paliadin.widget.title">Paliadin</span>
|
||||
</h2>
|
||||
<div className="paliadin-widget-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="paliadin-widget-reset"
|
||||
className="paliadin-widget-action-btn"
|
||||
aria-label="Reset"
|
||||
title="Konversation zurücksetzen"
|
||||
data-i18n-title="paliadin.widget.reset"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_RESET }} />
|
||||
</button>
|
||||
<a
|
||||
href="/paliadin"
|
||||
id="paliadin-widget-fullscreen"
|
||||
className="paliadin-widget-action-btn"
|
||||
aria-label="Fullscreen"
|
||||
title="Vollbild-Modus"
|
||||
data-i18n-title="paliadin.widget.fullscreen"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_FULLSCREEN }} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
id="paliadin-widget-close"
|
||||
className="paliadin-widget-action-btn"
|
||||
aria-label="Close"
|
||||
title="Schließen"
|
||||
data-i18n-title="paliadin.widget.close"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_CLOSE }} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Context chip — shows what Paliadin knows about the current page
|
||||
(route + primary entity). Populated by paliadin-widget.ts from
|
||||
computePaliadinContext() each time the drawer opens.
|
||||
*/}
|
||||
<div
|
||||
id="paliadin-widget-context-chip"
|
||||
className="paliadin-widget-context-chip"
|
||||
style="display:none"
|
||||
>
|
||||
<span className="paliadin-widget-context-label"
|
||||
data-i18n="paliadin.widget.context.on_page">Auf dieser Seite</span>
|
||||
<span className="paliadin-widget-context-value" id="paliadin-widget-context-value" />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Message stream + empty-state starter prompts. Empty state
|
||||
renders the per-route starter list from
|
||||
frontend/src/client/paliadin-starters.ts; on first send it
|
||||
slides out and the messages take over.
|
||||
*/}
|
||||
<div
|
||||
id="paliadin-widget-messages"
|
||||
className="paliadin-widget-messages"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="paliadin-widget-empty" id="paliadin-widget-empty">
|
||||
<p className="paliadin-widget-empty-prompt"
|
||||
data-i18n="paliadin.widget.empty">Was kann ich für dich tun?</p>
|
||||
<div
|
||||
className="paliadin-widget-starters"
|
||||
id="paliadin-widget-starters"
|
||||
>
|
||||
{/* Populated by paliadin-widget.ts from the per-route registry. */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="paliadin-widget-form" id="paliadin-widget-form">
|
||||
<textarea
|
||||
className="paliadin-widget-input"
|
||||
id="paliadin-widget-input"
|
||||
rows={2}
|
||||
placeholder="Frage an Paliadin..."
|
||||
data-i18n-placeholder="paliadin.widget.input.placeholder"
|
||||
aria-label="Nachricht an Paliadin"
|
||||
data-i18n-aria-label="paliadin.widget.input.label"
|
||||
maxlength={4000}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="paliadin-widget-send-btn"
|
||||
id="paliadin-widget-send-btn"
|
||||
aria-label="Senden"
|
||||
title="Senden"
|
||||
data-i18n-title="paliadin.widget.send"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_SEND }} />
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<script src="/assets/paliadin-widget.js" defer />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -153,6 +153,20 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-description" data-i18n="projects.field.description">Notizen</label>
|
||||
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projects.field.description.placeholder" />
|
||||
|
||||
@@ -17,7 +17,6 @@ const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
||||
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
|
||||
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
|
||||
const ICON_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||
@@ -25,6 +24,12 @@ const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
||||
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
||||
const ICON_AUDIT_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
|
||||
// Newspaper icon for the /changelog "Neuigkeiten" entry. Sparkle is now
|
||||
// reserved for the Paliadin AI surface so the two affordances don't
|
||||
// share a glyph (m's 2026-05-08 21:11 dogfood).
|
||||
const ICON_NEWSPAPER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8z"/></svg>';
|
||||
// Bell icon for the /inbox entry (t-paliad-138 4-eye approval inbox).
|
||||
const ICON_BELL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
// Theme-toggle icons. The button cycles auto → light → dark → auto, and
|
||||
// the icon swaps to reflect the *current* preference (auto/light/dark)
|
||||
// — not the eventual click target. SSR renders the auto variant; the
|
||||
@@ -44,7 +49,7 @@ interface SidebarProps {
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string): string {
|
||||
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string, badgeID?: string): string {
|
||||
// "Active" is true for the item whose href is a prefix of currentPath.
|
||||
// That way sub-routes like /projekte/{id}/events keep the /projekte entry lit.
|
||||
// /akten and /akten/* are kept as legacy aliases and also highlight /projekte
|
||||
@@ -55,6 +60,7 @@ function navItem(href: string, icon: string, i18nKey: string, label: string, cur
|
||||
<a href={href} className={`sidebar-item${active ? " active" : ""}`}>
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: icon }} />
|
||||
<span className="sidebar-label" data-i18n={i18nKey}>{label}</span>
|
||||
{badgeID ? <span className="sidebar-badge" id={badgeID} style="display:none" aria-hidden="true" /> : ""}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -109,33 +115,55 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
|
||||
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
|
||||
|
||||
{/* Paliadin top-level entry (t-paliad-162) \u2014 owner-only, hidden
|
||||
by default. sidebar.ts reveals it after /api/me confirms the
|
||||
caller is the Paliadin owner (t-paliad-146 PoC scope). Same
|
||||
fail-closed pattern as the admin group below. Sits directly
|
||||
under Home per m's design call so owners hit their assistant
|
||||
with one click from anywhere. */}
|
||||
<a href="/paliadin"
|
||||
className={`sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}`}
|
||||
id="sidebar-paliadin-link" style="display:none">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>
|
||||
</a>
|
||||
|
||||
{group("nav.group.uebersicht", "\u00DCbersicht",
|
||||
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
|
||||
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
|
||||
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
|
||||
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
|
||||
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.arbeit", "Arbeit",
|
||||
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
|
||||
navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath) +
|
||||
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
|
||||
)}
|
||||
{/* Ansichten \u2014 single consolidated group (m's 2026-05-08 20:32
|
||||
dogfood: "all views under one — not Ansichten and meine Ansichten").
|
||||
Holds the built-in Fristen + Termine, the user-defined views
|
||||
hydrated by client/sidebar.ts from /api/user-views, and the
|
||||
"+ Neue Sicht" entry. The previous "Meine Sichten" split is gone. */}
|
||||
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
|
||||
<div className="sidebar-group-label" data-i18n="nav.group.ansichten">Ansichten</div>
|
||||
{navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath)}
|
||||
{navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath)}
|
||||
<div className="sidebar-views-items" id="sidebar-views-items" />
|
||||
<a href="/views/new" className="sidebar-item sidebar-views-new">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
|
||||
<span className="sidebar-label" data-i18n="nav.user_views.new">Neue Sicht</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-162 — single Werkzeuge group consolidating the prior
|
||||
Werkzeuge / Wissen / Ressourcen splits. Order follows m's
|
||||
brief: calculators first, then reference (Checklisten /
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.wissen", "Wissen",
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
navItem("/courts", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath) +
|
||||
navItem("/glossary", ICON_BOOK, "nav.glossar", "Glossar", currentPath) +
|
||||
navItem("/courts", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.ressourcen", "Ressourcen",
|
||||
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath) +
|
||||
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath),
|
||||
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath) +
|
||||
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.einstellungen", "Einstellungen",
|
||||
@@ -154,6 +182,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
||||
style="display:none">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.admin.paliadin">Paliadin Monitor</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -162,7 +197,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
<div className="sidebar-bottom">
|
||||
{authenticated ? (
|
||||
<a href="/changelog" className={`sidebar-item sidebar-changelog${currentPath === "/changelog" ? " active" : ""}`} id="sidebar-changelog-link">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_NEWSPAPER }} />
|
||||
<span className="sidebar-label" data-i18n="nav.neuigkeiten">Neuigkeiten</span>
|
||||
<span className="sidebar-badge" id="sidebar-changelog-badge" style="display:none" aria-hidden="true" />
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -120,6 +121,7 @@ export function renderCourts(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/courts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -11,6 +12,35 @@ import { PWAHead } from "./components/PWAHead";
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
|
||||
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
|
||||
// it 90deg clockwise when the section is open via the
|
||||
// .dashboard-section[aria-expanded="true"] selector — see global.css.
|
||||
const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
|
||||
|
||||
// Render a collapsible dashboard section. The toggle button is the entire
|
||||
// header row so the heading text doubles as the affordance. State is
|
||||
// hydrated client-side from localStorage by client/dashboard.ts; SSR
|
||||
// renders all sections expanded so unstyled fallback is sensible.
|
||||
function CollapsibleSection(props: {
|
||||
id: string;
|
||||
headingI18n: string;
|
||||
headingDe: string;
|
||||
children: any;
|
||||
}): string {
|
||||
return (
|
||||
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
|
||||
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
|
||||
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
|
||||
<span className="dashboard-section-chevron" aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: ICON_CHEVRON }} />
|
||||
</button>
|
||||
<div className="dashboard-section-body">
|
||||
{props.children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderDashboard(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
@@ -58,10 +88,7 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<section className="dashboard-summary" aria-labelledby="dashboard-summary-heading">
|
||||
<h2 id="dashboard-summary-heading" className="dashboard-section-heading" data-i18n="dashboard.summary.heading">
|
||||
Fristen auf einen Blick
|
||||
</h2>
|
||||
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
@@ -84,9 +111,11 @@ export function renderDashboard(): string {
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Matter summary card */}
|
||||
{/* Matter summary card — single tappable card, kept outside the
|
||||
collapsible scaffold because its h3 is internal to the card
|
||||
and doubles as the navigation affordance. */}
|
||||
<section className="dashboard-matters">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
@@ -110,38 +139,59 @@ export function renderDashboard(): string {
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{/* Two-column lists */}
|
||||
{/* Two-column lists — each column is its own collapsible section
|
||||
so users can hide deadlines or appointments independently.
|
||||
The .dashboard-columns wrapper is preserved so the grid
|
||||
layout still applies; collapse hides the body of each col
|
||||
but leaves the heading row in the grid. */}
|
||||
<div className="dashboard-columns">
|
||||
<section className="dashboard-col">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.deadlines.heading">Kommende Fristen</h3>
|
||||
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
|
||||
<section className="dashboard-col">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.appointments.heading">Kommende Termine</h3>
|
||||
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Activity feed */}
|
||||
<section className="dashboard-activity">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.activity.heading">Letzte Aktivität</h3>
|
||||
{/* Inline Agenda (t-paliad-162). Same item shape as the
|
||||
standalone /agenda page, rendered via the shared
|
||||
agenda-render module. The dashboard variant is read-only:
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
Keine Fälligkeiten in den nächsten 30 Tagen.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollständige Agenda öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
</p>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -75,6 +76,7 @@ export function renderDeadlinesCalendar(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -43,6 +44,9 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
<span id="deadline-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
|
||||
Wartet auf Genehmigung
|
||||
</span>
|
||||
<a id="deadline-project-link" className="entity-ref" href="#" />
|
||||
<select id="deadline-project-edit" className="entity-ref-select" style="display:none" />
|
||||
</div>
|
||||
@@ -54,6 +58,9 @@ export function renderDeadlinesDetail(): string {
|
||||
<button id="deadline-reopen-btn" type="button" className="btn-primary btn-cta-lime btn-small" style="display:none" data-i18n="deadlines.detail.reopen">
|
||||
Wieder öffnen
|
||||
</button>
|
||||
<button id="deadline-withdraw-btn" type="button" className="btn-secondary btn-small" style="display:none" data-i18n="approvals.withdraw.cta">
|
||||
Genehmigungsanfrage zurückziehen
|
||||
</button>
|
||||
<button id="deadline-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="deadlines.detail.edit" title="Bearbeiten">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
@@ -135,6 +142,7 @@ export function renderDeadlinesDetail(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -54,9 +55,50 @@ export function renderDeadlinesNew(): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
@@ -80,6 +122,16 @@ export function renderDeadlinesNew(): string {
|
||||
|
||||
<p className="form-msg" id="deadline-new-msg" />
|
||||
|
||||
{/* t-paliad-154 — form-time 4-eye hint. Hidden by default;
|
||||
revealed by client TS when an effective policy applies
|
||||
to the chosen project. */}
|
||||
<div className="approval-hint" id="deadline-approval-hint" style="display:none">
|
||||
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
|
||||
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
|
||||
}} />
|
||||
<span id="deadline-approval-hint-text" />
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<a href="/events?type=deadline" id="deadline-new-cancel" className="btn-cancel" data-i18n="deadlines.neu.cancel">Abbrechen</a>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="deadlines.neu.submit">Frist anlegen</button>
|
||||
@@ -90,6 +142,7 @@ export function renderDeadlinesNew(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -75,6 +76,7 @@ export function renderDownloads(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/downloads.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -284,6 +285,7 @@ export function renderEvents(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/events.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -112,27 +113,102 @@ export function renderFristenrechner(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v3 landing fork (t-paliad-133) — visible by default, hidden once
|
||||
the user picks a pathway. URL ?path= drives visibility. */}
|
||||
<div className="fristen-pathway-fork" id="fristen-pathway-fork" role="group" aria-label="Pathway selector">
|
||||
<h2 className="fristen-pathway-fork-heading" data-i18n="deadlines.pathway.fork.heading">Was möchten Sie tun?</h2>
|
||||
<div className="fristen-pathway-fork-cards">
|
||||
<button type="button" className="fristen-pathway-card" data-path="a" id="fristen-pathway-a-cta">
|
||||
<span className="fristen-pathway-card-icon" aria-hidden="true">📖</span>
|
||||
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
|
||||
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.a.desc">
|
||||
Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.
|
||||
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
|
||||
Akte (project) that scopes the rest of the flow. Filtered
|
||||
list of visible projects + "Neue Akte anlegen" link +
|
||||
four ad-hoc explore-mode chips for users who just want to
|
||||
look up a rule without saving anywhere. */}
|
||||
<div className="fristen-step1" id="fristen-step1" role="group" aria-label="Akte picker">
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step1.heading">
|
||||
Schritt 1 — Welche Akte?
|
||||
</h2>
|
||||
<div className="fristen-step1-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-akte-search"
|
||||
className="fristen-akte-search" autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.step1.search.placeholder"
|
||||
placeholder="Akte suchen…" />
|
||||
</div>
|
||||
<ul className="fristen-akte-list" id="fristen-akte-list" role="listbox" aria-label="Akten"></ul>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
|
||||
</div>
|
||||
{/* return-bounce: projects-new.ts honours ?return= and
|
||||
redirects back to /tools/fristenrechner?project=<new_uuid>
|
||||
so the new Akte preselects itself in Step 1. */}
|
||||
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
|
||||
data-i18n="deadlines.step1.new.cta">
|
||||
+ Neue Akte anlegen
|
||||
</a>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.adhoc">oder ad-hoc, ohne Akte</span>
|
||||
</div>
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
Custom UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
Custom DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
Custom EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
Custom DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 collapsed summary, shown after a pick. Mirrors the
|
||||
proceeding-summary collapse pattern from 097e21c. */}
|
||||
<div className="fristen-step1-summary" id="fristen-step1-summary" style="display:none" role="group">
|
||||
<span className="fristen-step1-summary-label" data-i18n="deadlines.step1.selected">Akte:</span>
|
||||
<strong className="fristen-step1-summary-name" id="fristen-step1-summary-name">—</strong>
|
||||
<span className="fristen-step1-summary-meta" id="fristen-step1-summary-meta"></span>
|
||||
<button type="button" className="fristen-step1-summary-reselect" id="fristen-step1-summary-reselect"
|
||||
data-i18n="deadlines.step1.reselect">
|
||||
Andere Akte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is
|
||||
satisfied. Click on a card routes to the existing Pathway A
|
||||
(Verfahrensablauf wizard) or Pathway B (cascade) shells —
|
||||
we keep the routing primitive in showPathway()/showBMode(). */}
|
||||
<div className="fristen-step2" id="fristen-step2" hidden>
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step2.heading">
|
||||
Schritt 2 — Was möchten Sie tun?
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" data-action="file" id="fristen-step2-file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">✏️</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.file.title">
|
||||
Etwas einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.file.desc">
|
||||
Outgoing — eine Frist tritt aus eigener Handlung ein.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-pathway-card" data-path="b" id="fristen-pathway-b-cta">
|
||||
<span className="fristen-pathway-card-icon" aria-hidden="true">📅</span>
|
||||
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.b.desc">
|
||||
Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.
|
||||
<button type="button" className="fristen-step2-card" data-action="happened" id="fristen-step2-happened">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📥</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.happened.title">
|
||||
Etwas ist passiert
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.happened.desc">
|
||||
Incoming — ein Ereignis hat eine Frist ausgelöst.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-pathway-fork-shortcut">
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
oder direkt zu einer Frist springen:
|
||||
</div>
|
||||
@@ -170,8 +246,62 @@ export function renderFristenrechner(): string {
|
||||
fristen-b1-cascade hosts the breadcrumb / question / button row.
|
||||
fristen-b1-results hosts the narrowing concept-card list,
|
||||
populated by runB1Search() in fristenrechner.ts. The cards
|
||||
reuse renderConceptCard() (B2's card shape). */}
|
||||
reuse renderConceptCard() (B2's card shape).
|
||||
|
||||
m/paliad#15 follow-up: the inbox-channel chip lives at the
|
||||
top of THIS panel (not page-level) — m's call: "inside the
|
||||
decision tree because it helps us to determine what to do
|
||||
next". The chip narrows the cascade entry-points + B2 fine
|
||||
forum filter; Pathway A's Verlauf doesn't see it. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
{/* Slice 3c — perspective chip strip. Klägerseite vs
|
||||
Beklagtenseite hides cascade leaves whose party tag
|
||||
contradicts the user's side. "Beide" / no chip
|
||||
leaves the cascade unfiltered. */}
|
||||
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
|
||||
data-i18n-title="deadlines.perspective.claimant.title" title="Klägerseite (Proactive)">
|
||||
<span data-i18n="deadlines.perspective.claimant.short">Kläger</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
|
||||
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
|
||||
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
|
||||
data-i18n-title="deadlines.inbox.cms.title" title="UPC — über CMS">
|
||||
CMS
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
|
||||
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren — über beA">
|
||||
beA
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
|
||||
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren — Postzustellung">
|
||||
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
|
||||
<span data-i18n="deadlines.inbox.all">Alle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
@@ -215,6 +345,54 @@ export function renderFristenrechner(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3a — outgoing-intent chooser. Reached when the user
|
||||
picks "Etwas einreichen" on Step 2. Three options per
|
||||
m's 2026-05-08 18:09 spec: File (drives the Pathway A
|
||||
wizard), Draft (future drafting surface; v1
|
||||
placeholder), Enter (routes to the existing manual-
|
||||
create form). */}
|
||||
<div className="fristen-pathway-shell" id="fristen-step3a" data-path="outgoing" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-step3a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">✏️</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.heading">Was möchten Sie einreichen?</span>
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-file" data-action="file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📝</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.file.title">
|
||||
Schriftsatz einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.file.desc">
|
||||
Verfahrensablauf laden — Frist berechnen und zur Akte hinzufügen.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card fristen-step2-card--soon" id="fristen-step3a-draft" data-action="draft" disabled
|
||||
data-i18n-title="deadlines.step3a.soon">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">🖉</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.draft.title">
|
||||
Schriftsatz entwerfen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.draft.desc">
|
||||
Vorbereitung — später mit Drafting-Surface verknüpft.
|
||||
</span>
|
||||
<span className="fristen-step2-card-soon" data-i18n="deadlines.step3a.soon">kommt bald</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-enter" data-action="enter">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">💾</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.enter.title">
|
||||
Frist manuell erfassen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.enter.desc">
|
||||
Direkt eintragen — bereits bekanntes Datum / bekannter Typ.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway A container — wraps the existing wizard.
|
||||
Hidden until ?path=a. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
|
||||
@@ -238,33 +416,47 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DE_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* m's 2026-05-08 18:26: collapse the proceeding picker once
|
||||
a choice is made; this summary line replaces the four
|
||||
group blocks with a one-line "Selected: X [Reselect]"
|
||||
affordance. JS toggles `.proceeding-summary` visibility
|
||||
in lockstep with `.proceeding-group` blocks. */}
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
@@ -329,12 +521,12 @@ export function renderFristenrechner(): string {
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" checked />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -432,6 +624,7 @@ export function renderFristenrechner(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/fristenrechner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -259,6 +260,7 @@ export function renderGebuehrentabellen(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/gebuehrentabellen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -130,6 +131,7 @@ export function renderGlossary(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/glossary.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,50 @@
|
||||
// `data-i18n*` attributes in TSX/TS sources.
|
||||
|
||||
export type I18nKey =
|
||||
| "admin.approval_policies.bulk.cta"
|
||||
| "admin.approval_policies.bulk.modal.applying"
|
||||
| "admin.approval_policies.bulk.modal.body"
|
||||
| "admin.approval_policies.bulk.modal.cancel"
|
||||
| "admin.approval_policies.bulk.modal.confirm"
|
||||
| "admin.approval_policies.bulk.modal.done"
|
||||
| "admin.approval_policies.bulk.modal.targets_label"
|
||||
| "admin.approval_policies.bulk.modal.title"
|
||||
| "admin.approval_policies.bulk.modal.writes_label"
|
||||
| "admin.approval_policies.bulk.no_descendants"
|
||||
| "admin.approval_policies.cell.clear"
|
||||
| "admin.approval_policies.cell.clear.title"
|
||||
| "admin.approval_policies.cell.error_msg"
|
||||
| "admin.approval_policies.cell.requires"
|
||||
| "admin.approval_policies.cell.saved_msg"
|
||||
| "admin.approval_policies.entity.appointment"
|
||||
| "admin.approval_policies.entity.deadline"
|
||||
| "admin.approval_policies.heading"
|
||||
| "admin.approval_policies.lifecycle.complete"
|
||||
| "admin.approval_policies.lifecycle.create"
|
||||
| "admin.approval_policies.lifecycle.delete"
|
||||
| "admin.approval_policies.lifecycle.update"
|
||||
| "admin.approval_policies.loading"
|
||||
| "admin.approval_policies.picker.label"
|
||||
| "admin.approval_policies.picker.no_results"
|
||||
| "admin.approval_policies.picker.placeholder"
|
||||
| "admin.approval_policies.role.associate"
|
||||
| "admin.approval_policies.role.no_rule"
|
||||
| "admin.approval_policies.role.none"
|
||||
| "admin.approval_policies.role.of_counsel"
|
||||
| "admin.approval_policies.role.pa"
|
||||
| "admin.approval_policies.role.partner"
|
||||
| "admin.approval_policies.role.senior_pa"
|
||||
| "admin.approval_policies.section.projects"
|
||||
| "admin.approval_policies.section.projects.hint"
|
||||
| "admin.approval_policies.section.units"
|
||||
| "admin.approval_policies.section.units.hint"
|
||||
| "admin.approval_policies.source.ancestor"
|
||||
| "admin.approval_policies.source.no_approval"
|
||||
| "admin.approval_policies.source.project"
|
||||
| "admin.approval_policies.source.unit_default"
|
||||
| "admin.approval_policies.subtitle"
|
||||
| "admin.approval_policies.title"
|
||||
| "admin.approval_policies.units.empty"
|
||||
| "admin.audit.col.actor"
|
||||
| "admin.audit.col.description"
|
||||
| "admin.audit.col.event"
|
||||
@@ -46,8 +90,25 @@ export type I18nKey =
|
||||
| "admin.audit.source.reminder_log"
|
||||
| "admin.audit.subtitle"
|
||||
| "admin.audit.title"
|
||||
| "admin.broadcasts.col.count"
|
||||
| "admin.broadcasts.col.sender"
|
||||
| "admin.broadcasts.col.sent_at"
|
||||
| "admin.broadcasts.col.subject"
|
||||
| "admin.broadcasts.detail.delivered"
|
||||
| "admin.broadcasts.detail.failed"
|
||||
| "admin.broadcasts.detail.recipients"
|
||||
| "admin.broadcasts.detail.sent_by"
|
||||
| "admin.broadcasts.empty"
|
||||
| "admin.broadcasts.heading"
|
||||
| "admin.broadcasts.loading"
|
||||
| "admin.broadcasts.subtitle"
|
||||
| "admin.broadcasts.title"
|
||||
| "admin.card.approval_policies.desc"
|
||||
| "admin.card.approval_policies.title"
|
||||
| "admin.card.audit.desc"
|
||||
| "admin.card.audit.title"
|
||||
| "admin.card.broadcasts.desc"
|
||||
| "admin.card.broadcasts.title"
|
||||
| "admin.card.email_templates.desc"
|
||||
| "admin.card.email_templates.title"
|
||||
| "admin.card.event_types.desc"
|
||||
@@ -150,6 +211,28 @@ export type I18nKey =
|
||||
| "admin.event_types.subtitle"
|
||||
| "admin.event_types.title"
|
||||
| "admin.heading"
|
||||
| "admin.paliadin.abandon_rate"
|
||||
| "admin.paliadin.classifier_heading"
|
||||
| "admin.paliadin.col.classifier"
|
||||
| "admin.paliadin.col.count"
|
||||
| "admin.paliadin.col.duration"
|
||||
| "admin.paliadin.col.origin"
|
||||
| "admin.paliadin.col.prompt"
|
||||
| "admin.paliadin.col.response"
|
||||
| "admin.paliadin.col.started"
|
||||
| "admin.paliadin.col.tools"
|
||||
| "admin.paliadin.col.user"
|
||||
| "admin.paliadin.daily_heading"
|
||||
| "admin.paliadin.heading"
|
||||
| "admin.paliadin.last7"
|
||||
| "admin.paliadin.loading"
|
||||
| "admin.paliadin.median_dur"
|
||||
| "admin.paliadin.recent_heading"
|
||||
| "admin.paliadin.subtitle"
|
||||
| "admin.paliadin.title"
|
||||
| "admin.paliadin.tool_rate"
|
||||
| "admin.paliadin.top_heading"
|
||||
| "admin.paliadin.total"
|
||||
| "admin.partner_units.action.delete"
|
||||
| "admin.partner_units.action.edit"
|
||||
| "admin.partner_units.action.members"
|
||||
@@ -167,6 +250,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.error.user_required"
|
||||
| "admin.partner_units.feedback.created"
|
||||
| "admin.partner_units.feedback.deleted"
|
||||
| "admin.partner_units.feedback.role_updated"
|
||||
| "admin.partner_units.feedback.updated"
|
||||
| "admin.partner_units.heading"
|
||||
| "admin.partner_units.loading"
|
||||
@@ -177,6 +261,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.member.heading"
|
||||
| "admin.partner_units.member.placeholder"
|
||||
| "admin.partner_units.member.remove"
|
||||
| "admin.partner_units.member.role"
|
||||
| "admin.partner_units.new"
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
@@ -195,6 +280,9 @@ export type I18nKey =
|
||||
| "admin.team.col.name"
|
||||
| "admin.team.col.office"
|
||||
| "admin.team.col.permission"
|
||||
| "admin.team.col.profession"
|
||||
| "admin.team.col.profession.none"
|
||||
| "admin.team.col.profession.none.hint"
|
||||
| "admin.team.confirm.delete"
|
||||
| "admin.team.direct_add.body"
|
||||
| "admin.team.direct_add.cancel"
|
||||
@@ -254,6 +342,9 @@ export type I18nKey =
|
||||
| "agenda.urgency.this_week"
|
||||
| "agenda.urgency.today"
|
||||
| "agenda.urgency.tomorrow"
|
||||
| "aggregation.attribution.on"
|
||||
| "aggregation.toggle.direct_only"
|
||||
| "aggregation.toggle.subtree"
|
||||
| "appointments.col.akte"
|
||||
| "appointments.col.location"
|
||||
| "appointments.col.start"
|
||||
@@ -293,6 +384,7 @@ export type I18nKey =
|
||||
| "appointments.filter.to"
|
||||
| "appointments.filter.type"
|
||||
| "appointments.filter.type.all"
|
||||
| "appointments.form.approval_hint"
|
||||
| "appointments.kalender.empty"
|
||||
| "appointments.kalender.heading"
|
||||
| "appointments.kalender.list"
|
||||
@@ -318,6 +410,65 @@ export type I18nKey =
|
||||
| "appointments.type.hearing"
|
||||
| "appointments.type.meeting"
|
||||
| "appointments.unavailable"
|
||||
| "approvals.action.approve"
|
||||
| "approvals.action.reject"
|
||||
| "approvals.action.revoke"
|
||||
| "approvals.agent.byline"
|
||||
| "approvals.agent.label"
|
||||
| "approvals.agent.suggestion_pending"
|
||||
| "approvals.decided_by"
|
||||
| "approvals.decision_kind.admin_override"
|
||||
| "approvals.decision_kind.derived_peer"
|
||||
| "approvals.decision_kind.peer"
|
||||
| "approvals.diff.after"
|
||||
| "approvals.diff.before"
|
||||
| "approvals.empty.mine"
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
| "approvals.entity.deadline"
|
||||
| "approvals.error.awaiting_approval"
|
||||
| "approvals.error.concurrent_pending"
|
||||
| "approvals.error.no_qualified_approver"
|
||||
| "approvals.error.not_authorized"
|
||||
| "approvals.error.request_not_pending"
|
||||
| "approvals.error.self_approval"
|
||||
| "approvals.heading"
|
||||
| "approvals.lifecycle.complete"
|
||||
| "approvals.lifecycle.create"
|
||||
| "approvals.lifecycle.delete"
|
||||
| "approvals.lifecycle.update"
|
||||
| "approvals.note.placeholder"
|
||||
| "approvals.pending.badge"
|
||||
| "approvals.pending_complete.label"
|
||||
| "approvals.pending_create.label"
|
||||
| "approvals.pending_delete.label"
|
||||
| "approvals.pending_update.label"
|
||||
| "approvals.policies.column.appointment"
|
||||
| "approvals.policies.column.deadline"
|
||||
| "approvals.policies.column.event"
|
||||
| "approvals.policies.copy_parent"
|
||||
| "approvals.policies.no_approval"
|
||||
| "approvals.policies.set_all_associate"
|
||||
| "approvals.policies.subtitle"
|
||||
| "approvals.policies.title"
|
||||
| "approvals.requested_by"
|
||||
| "approvals.required_role.associate"
|
||||
| "approvals.required_role.lead"
|
||||
| "approvals.required_role.of_counsel"
|
||||
| "approvals.required_role.pa"
|
||||
| "approvals.required_role.senior_pa"
|
||||
| "approvals.status.approved"
|
||||
| "approvals.status.pending"
|
||||
| "approvals.status.rejected"
|
||||
| "approvals.status.revoked"
|
||||
| "approvals.status.superseded"
|
||||
| "approvals.subtitle"
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.error"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -456,8 +607,16 @@ export type I18nKey =
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
| "common.load_error"
|
||||
| "common.loading"
|
||||
| "dashboard.action.short.akte_archived"
|
||||
| "dashboard.action.short.akte_created"
|
||||
| "dashboard.action.short.appointment_approval_approved"
|
||||
| "dashboard.action.short.appointment_approval_rejected"
|
||||
| "dashboard.action.short.appointment_approval_requested"
|
||||
| "dashboard.action.short.appointment_approval_revoked"
|
||||
| "dashboard.action.short.appointment_created"
|
||||
| "dashboard.action.short.appointment_deleted"
|
||||
| "dashboard.action.short.appointment_project_changed"
|
||||
@@ -475,6 +634,10 @@ export type I18nKey =
|
||||
| "dashboard.action.short.checkliste_reset"
|
||||
| "dashboard.action.short.checkliste_unlinked"
|
||||
| "dashboard.action.short.collaborators_updated"
|
||||
| "dashboard.action.short.deadline_approval_approved"
|
||||
| "dashboard.action.short.deadline_approval_rejected"
|
||||
| "dashboard.action.short.deadline_approval_requested"
|
||||
| "dashboard.action.short.deadline_approval_revoked"
|
||||
| "dashboard.action.short.deadline_completed"
|
||||
| "dashboard.action.short.deadline_created"
|
||||
| "dashboard.action.short.deadline_deleted"
|
||||
@@ -489,6 +652,7 @@ export type I18nKey =
|
||||
| "dashboard.action.short.fristen_imported"
|
||||
| "dashboard.action.short.note_created"
|
||||
| "dashboard.action.short.notiz_created"
|
||||
| "dashboard.action.short.our_side_changed"
|
||||
| "dashboard.action.short.partei_added"
|
||||
| "dashboard.action.short.partei_removed"
|
||||
| "dashboard.action.short.project_archived"
|
||||
@@ -507,6 +671,9 @@ export type I18nKey =
|
||||
| "dashboard.activity.event"
|
||||
| "dashboard.activity.heading"
|
||||
| "dashboard.activity.system"
|
||||
| "dashboard.agenda.empty"
|
||||
| "dashboard.agenda.full_link"
|
||||
| "dashboard.agenda.heading"
|
||||
| "dashboard.appointment_summary.heading"
|
||||
| "dashboard.appointments.empty"
|
||||
| "dashboard.appointments.heading"
|
||||
@@ -518,6 +685,8 @@ export type I18nKey =
|
||||
| "dashboard.matters.heading"
|
||||
| "dashboard.matters.total"
|
||||
| "dashboard.onboarding"
|
||||
| "dashboard.section.collapse"
|
||||
| "dashboard.section.expand"
|
||||
| "dashboard.summary.completed"
|
||||
| "dashboard.summary.heading"
|
||||
| "dashboard.summary.later"
|
||||
@@ -571,6 +740,7 @@ export type I18nKey =
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
| "deadlines.complete.action"
|
||||
| "deadlines.court.indirect"
|
||||
| "deadlines.court.label"
|
||||
| "deadlines.court.set"
|
||||
| "deadlines.date.edit.hint"
|
||||
@@ -650,7 +820,11 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
@@ -680,7 +854,14 @@ export type I18nKey =
|
||||
| "deadlines.flag.inf_amend"
|
||||
| "deadlines.flag.rev_amend"
|
||||
| "deadlines.flag.rev_cci"
|
||||
| "deadlines.form.approval_hint"
|
||||
| "deadlines.heading"
|
||||
| "deadlines.inbox.all"
|
||||
| "deadlines.inbox.bea.title"
|
||||
| "deadlines.inbox.cms.title"
|
||||
| "deadlines.inbox.label"
|
||||
| "deadlines.inbox.posteingang"
|
||||
| "deadlines.inbox.posteingang.title"
|
||||
| "deadlines.kalender.empty"
|
||||
| "deadlines.kalender.heading"
|
||||
| "deadlines.kalender.list"
|
||||
@@ -700,6 +881,7 @@ export type I18nKey =
|
||||
| "deadlines.neu.submit"
|
||||
| "deadlines.neu.subtitle"
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
| "deadlines.party.claimant"
|
||||
@@ -720,13 +902,22 @@ export type I18nKey =
|
||||
| "deadlines.pathway.fork.heading"
|
||||
| "deadlines.pathway.shortcut.label"
|
||||
| "deadlines.perspective.appeal_filed_by.label"
|
||||
| "deadlines.perspective.both.short"
|
||||
| "deadlines.perspective.claimant"
|
||||
| "deadlines.perspective.claimant.short"
|
||||
| "deadlines.perspective.claimant.title"
|
||||
| "deadlines.perspective.defendant"
|
||||
| "deadlines.perspective.defendant.short"
|
||||
| "deadlines.perspective.defendant.title"
|
||||
| "deadlines.perspective.label"
|
||||
| "deadlines.perspective.predefined_hint"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.proceeding.reselect"
|
||||
| "deadlines.proceeding.selected"
|
||||
| "deadlines.reset"
|
||||
| "deadlines.save.cta"
|
||||
| "deadlines.save.cta.adhoc.hint"
|
||||
| "deadlines.save.error"
|
||||
| "deadlines.save.modal.akte"
|
||||
| "deadlines.save.modal.akte.choose"
|
||||
@@ -765,8 +956,35 @@ export type I18nKey =
|
||||
| "deadlines.status.pending"
|
||||
| "deadlines.status.waived"
|
||||
| "deadlines.step1"
|
||||
| "deadlines.step1.adhoc.de"
|
||||
| "deadlines.step1.adhoc.dpma"
|
||||
| "deadlines.step1.adhoc.epa"
|
||||
| "deadlines.step1.adhoc.upc"
|
||||
| "deadlines.step1.divider.adhoc"
|
||||
| "deadlines.step1.divider.new"
|
||||
| "deadlines.step1.heading"
|
||||
| "deadlines.step1.new.cta"
|
||||
| "deadlines.step1.reselect"
|
||||
| "deadlines.step1.search.empty"
|
||||
| "deadlines.step1.search.placeholder"
|
||||
| "deadlines.step1.selected"
|
||||
| "deadlines.step1.summary.adhoc.suffix"
|
||||
| "deadlines.step2"
|
||||
| "deadlines.step2.file.desc"
|
||||
| "deadlines.step2.file.title"
|
||||
| "deadlines.step2.happened.desc"
|
||||
| "deadlines.step2.happened.title"
|
||||
| "deadlines.step2.heading"
|
||||
| "deadlines.step3"
|
||||
| "deadlines.step3a.back"
|
||||
| "deadlines.step3a.draft.desc"
|
||||
| "deadlines.step3a.draft.title"
|
||||
| "deadlines.step3a.enter.desc"
|
||||
| "deadlines.step3a.enter.title"
|
||||
| "deadlines.step3a.file.desc"
|
||||
| "deadlines.step3a.file.title"
|
||||
| "deadlines.step3a.heading"
|
||||
| "deadlines.step3a.soon"
|
||||
| "deadlines.subtitle"
|
||||
| "deadlines.summary.completed"
|
||||
| "deadlines.summary.later"
|
||||
@@ -847,10 +1065,18 @@ export type I18nKey =
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
| "event.description.appointment_approval_rejected"
|
||||
| "event.description.appointment_approval_requested"
|
||||
| "event.description.appointment_approval_revoked"
|
||||
| "event.description.appointment_created"
|
||||
| "event.description.appointment_deleted"
|
||||
| "event.description.appointment_project_changed"
|
||||
| "event.description.appointment_updated"
|
||||
| "event.description.deadline_approval_approved"
|
||||
| "event.description.deadline_approval_rejected"
|
||||
| "event.description.deadline_approval_requested"
|
||||
| "event.description.deadline_approval_revoked"
|
||||
| "event.description.deadline_completed"
|
||||
| "event.description.deadline_created"
|
||||
| "event.description.deadline_deleted"
|
||||
@@ -862,6 +1088,10 @@ export type I18nKey =
|
||||
| "event.note.parent.appointment"
|
||||
| "event.note.parent.deadline"
|
||||
| "event.note.parent.project"
|
||||
| "event.title.appointment_approval_approved"
|
||||
| "event.title.appointment_approval_rejected"
|
||||
| "event.title.appointment_approval_requested"
|
||||
| "event.title.appointment_approval_revoked"
|
||||
| "event.title.appointment_created"
|
||||
| "event.title.appointment_deleted"
|
||||
| "event.title.appointment_project_changed"
|
||||
@@ -872,6 +1102,10 @@ export type I18nKey =
|
||||
| "event.title.checklist_renamed"
|
||||
| "event.title.checklist_reset"
|
||||
| "event.title.checklist_unlinked"
|
||||
| "event.title.deadline_approval_approved"
|
||||
| "event.title.deadline_approval_rejected"
|
||||
| "event.title.deadline_approval_requested"
|
||||
| "event.title.deadline_approval_revoked"
|
||||
| "event.title.deadline_completed"
|
||||
| "event.title.deadline_created"
|
||||
| "event.title.deadline_deleted"
|
||||
@@ -880,6 +1114,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
| "event.title.project_created"
|
||||
| "event.title.project_reparented"
|
||||
@@ -1044,6 +1279,9 @@ export type I18nKey =
|
||||
| "glossar.suggest.success"
|
||||
| "glossar.suggest.title"
|
||||
| "glossar.title"
|
||||
| "inbox.empty.admin_nudge.body"
|
||||
| "inbox.empty.admin_nudge.cta"
|
||||
| "inbox.empty.admin_nudge.title"
|
||||
| "index.checklisten.desc"
|
||||
| "index.checklisten.title"
|
||||
| "index.cost.desc"
|
||||
@@ -1195,6 +1433,7 @@ export type I18nKey =
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
@@ -1210,21 +1449,23 @@ export type I18nKey =
|
||||
| "nav.gerichte"
|
||||
| "nav.glossar"
|
||||
| "nav.group.admin"
|
||||
| "nav.group.arbeit"
|
||||
| "nav.group.ansichten"
|
||||
| "nav.group.einstellungen"
|
||||
| "nav.group.ressourcen"
|
||||
| "nav.group.uebersicht"
|
||||
| "nav.group.user_views"
|
||||
| "nav.group.werkzeuge"
|
||||
| "nav.group.wissen"
|
||||
| "nav.home"
|
||||
| "nav.inbox"
|
||||
| "nav.kostenrechner"
|
||||
| "nav.links"
|
||||
| "nav.logout"
|
||||
| "nav.neuigkeiten"
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -1267,6 +1508,8 @@ export type I18nKey =
|
||||
| "onboarding.optional"
|
||||
| "onboarding.partner_unit"
|
||||
| "onboarding.partner_unit.unassigned"
|
||||
| "onboarding.profession"
|
||||
| "onboarding.profession.hint"
|
||||
| "onboarding.submit"
|
||||
| "onboarding.title"
|
||||
| "palette.action.app.invite"
|
||||
@@ -1292,11 +1535,107 @@ export type I18nKey =
|
||||
| "palette.footer.navigate"
|
||||
| "palette.footer.open"
|
||||
| "palette.section.actions"
|
||||
| "paliadin.empty"
|
||||
| "paliadin.error.connection_lost"
|
||||
| "paliadin.error.local_only"
|
||||
| "paliadin.error.mriver_unreachable"
|
||||
| "paliadin.error.shim_auth_failed"
|
||||
| "paliadin.error.shim_error"
|
||||
| "paliadin.error.timeout"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
| "paliadin.send"
|
||||
| "paliadin.starter.concept"
|
||||
| "paliadin.starter.today"
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.title"
|
||||
| "paliadin.widget.close"
|
||||
| "paliadin.widget.context.on_page"
|
||||
| "paliadin.widget.empty"
|
||||
| "paliadin.widget.fullscreen"
|
||||
| "paliadin.widget.input.label"
|
||||
| "paliadin.widget.input.placeholder"
|
||||
| "paliadin.widget.reset"
|
||||
| "paliadin.widget.reset.confirm"
|
||||
| "paliadin.widget.send"
|
||||
| "paliadin.widget.title"
|
||||
| "paliadin.widget.trigger"
|
||||
| "partner_unit.heading"
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "projects.cancel"
|
||||
| "projects.cards.deadline_open"
|
||||
| "projects.cards.deadline_overdue"
|
||||
| "projects.cards.empty"
|
||||
| "projects.cards.event.kind.appointment"
|
||||
| "projects.cards.event.kind.deadline"
|
||||
| "projects.cards.event.kind.project_event"
|
||||
| "projects.cards.layout.delete"
|
||||
| "projects.cards.layout.delete.confirm"
|
||||
| "projects.cards.layout.delete.default_blocked"
|
||||
| "projects.cards.layout.density"
|
||||
| "projects.cards.layout.density.compact"
|
||||
| "projects.cards.layout.density.roomy"
|
||||
| "projects.cards.layout.discard"
|
||||
| "projects.cards.layout.edit"
|
||||
| "projects.cards.layout.fact.client-matter"
|
||||
| "projects.cards.layout.fact.count"
|
||||
| "projects.cards.layout.fact.deadline-counts"
|
||||
| "projects.cards.layout.fact.last-activity-at"
|
||||
| "projects.cards.layout.fact.move_down"
|
||||
| "projects.cards.layout.fact.move_up"
|
||||
| "projects.cards.layout.fact.next-events"
|
||||
| "projects.cards.layout.fact.parent-path"
|
||||
| "projects.cards.layout.fact.recent-verlauf"
|
||||
| "projects.cards.layout.fact.reference"
|
||||
| "projects.cards.layout.fact.status-chip"
|
||||
| "projects.cards.layout.fact.team-chips"
|
||||
| "projects.cards.layout.fact.title-row"
|
||||
| "projects.cards.layout.fact.toggle.hide"
|
||||
| "projects.cards.layout.fact.toggle.show"
|
||||
| "projects.cards.layout.fact.type-chip"
|
||||
| "projects.cards.layout.grid"
|
||||
| "projects.cards.layout.grid.2"
|
||||
| "projects.cards.layout.grid.3"
|
||||
| "projects.cards.layout.grid.4"
|
||||
| "projects.cards.layout.grid.auto"
|
||||
| "projects.cards.layout.is_default"
|
||||
| "projects.cards.layout.label"
|
||||
| "projects.cards.layout.new"
|
||||
| "projects.cards.layout.new.prompt"
|
||||
| "projects.cards.layout.rename"
|
||||
| "projects.cards.layout.save"
|
||||
| "projects.cards.layout.set_default"
|
||||
| "projects.cards.next_events"
|
||||
| "projects.cards.no_next_events"
|
||||
| "projects.cards.no_recent"
|
||||
| "projects.cards.recent_verlauf"
|
||||
| "projects.cards.show_all_levels"
|
||||
| "projects.cards.show_all_levels.hint"
|
||||
| "projects.cards.team"
|
||||
| "projects.chip.all"
|
||||
| "projects.chip.has_open_deadlines"
|
||||
| "projects.chip.mine"
|
||||
| "projects.chip.multi.count"
|
||||
| "projects.chip.multi.none"
|
||||
| "projects.chip.pinned"
|
||||
| "projects.chip.status"
|
||||
| "projects.chip.status.active"
|
||||
| "projects.chip.status.archived"
|
||||
| "projects.chip.status.closed"
|
||||
| "projects.chip.type"
|
||||
| "projects.chip.type.case"
|
||||
| "projects.chip.type.client"
|
||||
| "projects.chip.type.litigation"
|
||||
| "projects.chip.type.patent"
|
||||
| "projects.chip.type.project"
|
||||
| "projects.col.clientmatter"
|
||||
| "projects.col.office"
|
||||
| "projects.col.ref"
|
||||
@@ -1363,21 +1702,30 @@ export type I18nKey =
|
||||
| "projects.detail.tab.verlauf"
|
||||
| "projects.detail.team.add"
|
||||
| "projects.detail.team.col.name"
|
||||
| "projects.detail.team.col.profession"
|
||||
| "projects.detail.team.col.responsibility"
|
||||
| "projects.detail.team.col.role"
|
||||
| "projects.detail.team.col.source"
|
||||
| "projects.detail.team.confirm_remove"
|
||||
| "projects.detail.team.empty"
|
||||
| "projects.detail.team.error.user_required"
|
||||
| "projects.detail.team.form.cancel"
|
||||
| "projects.detail.team.form.profession.label"
|
||||
| "projects.detail.team.form.profession.none"
|
||||
| "projects.detail.team.form.responsibility"
|
||||
| "projects.detail.team.form.role"
|
||||
| "projects.detail.team.form.submit"
|
||||
| "projects.detail.team.form.user"
|
||||
| "projects.detail.team.invite.cta"
|
||||
| "projects.detail.team.invite.hint"
|
||||
| "projects.detail.team.invite.hint_email"
|
||||
| "projects.detail.team.remove"
|
||||
| "projects.detail.title"
|
||||
| "projects.detail.verlauf.empty"
|
||||
| "projects.detail.verlauf.loadMore"
|
||||
| "projects.detail.verlauf.loadingMore"
|
||||
| "projects.empty.filtered"
|
||||
| "projects.empty.filtered.action"
|
||||
| "projects.empty.hint"
|
||||
| "projects.empty.title"
|
||||
| "projects.error.forbidden"
|
||||
@@ -1405,6 +1753,14 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
@@ -1436,14 +1792,34 @@ export type I18nKey =
|
||||
| "projects.neu.title"
|
||||
| "projects.new"
|
||||
| "projects.onboarding.required"
|
||||
| "projects.search.match.ancestor"
|
||||
| "projects.search.match.descendant"
|
||||
| "projects.search.match.self"
|
||||
| "projects.search.placeholder"
|
||||
| "projects.status.active"
|
||||
| "projects.status.archived"
|
||||
| "projects.status.completed"
|
||||
| "projects.submit"
|
||||
| "projects.subtitle"
|
||||
| "projects.team.derived.authority"
|
||||
| "projects.team.derived.authority.hint"
|
||||
| "projects.team.derived.from"
|
||||
| "projects.team.derived.visibility"
|
||||
| "projects.team.direct"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
| "projects.team.profession.none"
|
||||
| "projects.team.profession.none.hint"
|
||||
| "projects.team.profession.of_counsel"
|
||||
| "projects.team.profession.pa"
|
||||
| "projects.team.profession.paralegal"
|
||||
| "projects.team.profession.partner"
|
||||
| "projects.team.profession.senior_pa"
|
||||
| "projects.team.responsibility.external"
|
||||
| "projects.team.responsibility.lead"
|
||||
| "projects.team.responsibility.member"
|
||||
| "projects.team.responsibility.observer"
|
||||
| "projects.team.role.associate"
|
||||
| "projects.team.role.expert"
|
||||
| "projects.team.role.lead"
|
||||
@@ -1451,12 +1827,40 @@ export type I18nKey =
|
||||
| "projects.team.role.observer"
|
||||
| "projects.team.role.of_counsel"
|
||||
| "projects.team.role.pa"
|
||||
| "projects.team.section.derived"
|
||||
| "projects.team.section.derived.hint"
|
||||
| "projects.team.section.from_descendants"
|
||||
| "projects.team.section.from_descendants.hint"
|
||||
| "projects.team.section.units"
|
||||
| "projects.team.section.units.hint"
|
||||
| "projects.team.units.attach"
|
||||
| "projects.team.units.choose"
|
||||
| "projects.team.units.col.authority"
|
||||
| "projects.team.units.col.derive_roles"
|
||||
| "projects.team.units.col.name"
|
||||
| "projects.team.units.confirm_detach"
|
||||
| "projects.team.units.derive_roles"
|
||||
| "projects.team.units.detach"
|
||||
| "projects.team.units.empty"
|
||||
| "projects.team.units.grants_authority"
|
||||
| "projects.team.units.members"
|
||||
| "projects.team.units.select"
|
||||
| "projects.title"
|
||||
| "projects.toolbar.search.placeholder"
|
||||
| "projects.toolbar.subtree_counts"
|
||||
| "projects.toolbar.view.cards"
|
||||
| "projects.toolbar.view.flat"
|
||||
| "projects.toolbar.view.tree"
|
||||
| "projects.tree.deadlines.direct.tooltip"
|
||||
| "projects.tree.deadlines.open"
|
||||
| "projects.tree.deadlines.overdue"
|
||||
| "projects.tree.deadlines.subtree.tooltip"
|
||||
| "projects.tree.error"
|
||||
| "projects.tree.inherited.context"
|
||||
| "projects.tree.loading"
|
||||
| "projects.tree.pin"
|
||||
| "projects.tree.toggle"
|
||||
| "projects.tree.unpin"
|
||||
| "projects.type.case"
|
||||
| "projects.type.client"
|
||||
| "projects.type.litigation"
|
||||
@@ -1477,10 +1881,38 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
| "team.broadcast.error.body_required"
|
||||
| "team.broadcast.error.no_recipients"
|
||||
| "team.broadcast.error.subject_required"
|
||||
| "team.broadcast.error.too_many"
|
||||
| "team.broadcast.mailto.label"
|
||||
| "team.broadcast.mailto.tooltip"
|
||||
| "team.broadcast.markdown_hint"
|
||||
| "team.broadcast.placeholders_hint"
|
||||
| "team.broadcast.recipients"
|
||||
| "team.broadcast.send"
|
||||
| "team.broadcast.sending"
|
||||
| "team.broadcast.sent"
|
||||
| "team.broadcast.show_all"
|
||||
| "team.broadcast.subject"
|
||||
| "team.broadcast.success"
|
||||
| "team.broadcast.template"
|
||||
| "team.broadcast.template.deadline_digest"
|
||||
| "team.broadcast.template.invitation"
|
||||
| "team.broadcast.template_freeform"
|
||||
| "team.broadcast.template_optional"
|
||||
| "team.broadcast.title"
|
||||
| "team.dept.lead"
|
||||
| "team.dept.unassigned"
|
||||
| "team.empty"
|
||||
| "team.filter.all"
|
||||
| "team.filter.project"
|
||||
| "team.filter.project.all"
|
||||
| "team.filter.project.clear"
|
||||
| "team.filter.project.selected"
|
||||
| "team.filter.role"
|
||||
| "team.group.department"
|
||||
| "team.group.office"
|
||||
@@ -1505,4 +1937,153 @@ export type I18nKey =
|
||||
| "theme.toggle.cycle.dark"
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light";
|
||||
| "theme.toggle.light"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa"
|
||||
| "views.action.edit"
|
||||
| "views.bar.action.reset"
|
||||
| "views.bar.action.save_as_view"
|
||||
| "views.bar.appointment_type.consultation"
|
||||
| "views.bar.appointment_type.deadline_hearing"
|
||||
| "views.bar.appointment_type.hearing"
|
||||
| "views.bar.appointment_type.meeting"
|
||||
| "views.bar.approval_entity.appointment"
|
||||
| "views.bar.approval_entity.deadline"
|
||||
| "views.bar.approval_role.any_visible"
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
| "views.bar.common.all"
|
||||
| "views.bar.deadline_status.completed"
|
||||
| "views.bar.deadline_status.pending"
|
||||
| "views.bar.density.comfortable"
|
||||
| "views.bar.density.compact"
|
||||
| "views.bar.label.appointment_type"
|
||||
| "views.bar.label.approval_entity"
|
||||
| "views.bar.label.approval_role"
|
||||
| "views.bar.label.approval_status"
|
||||
| "views.bar.label.deadline_status"
|
||||
| "views.bar.label.density"
|
||||
| "views.bar.label.personal"
|
||||
| "views.bar.label.shape"
|
||||
| "views.bar.label.sort"
|
||||
| "views.bar.label.time"
|
||||
| "views.bar.personal.on"
|
||||
| "views.bar.save.cancel"
|
||||
| "views.bar.save.confirm"
|
||||
| "views.bar.save.error.name_required"
|
||||
| "views.bar.save.error.network"
|
||||
| "views.bar.save.error.slug_format"
|
||||
| "views.bar.save.error.slug_taken"
|
||||
| "views.bar.save.field.name"
|
||||
| "views.bar.save.field.show_count"
|
||||
| "views.bar.save.field.slug"
|
||||
| "views.bar.save.field.slug_hint"
|
||||
| "views.bar.save.heading"
|
||||
| "views.bar.shape.calendar"
|
||||
| "views.bar.shape.cards"
|
||||
| "views.bar.shape.list"
|
||||
| "views.bar.sort.date_asc"
|
||||
| "views.bar.sort.date_desc"
|
||||
| "views.bar.time.any"
|
||||
| "views.bar.time.custom"
|
||||
| "views.bar.time.custom.coming_soon"
|
||||
| "views.bar.time.next_30d"
|
||||
| "views.bar.time.next_7d"
|
||||
| "views.bar.time.next_90d"
|
||||
| "views.bar.time.past_30d"
|
||||
| "views.calendar.mobile_fallback"
|
||||
| "views.col.actor"
|
||||
| "views.col.appointment_type"
|
||||
| "views.col.approval_status"
|
||||
| "views.col.date"
|
||||
| "views.col.decided_by"
|
||||
| "views.col.event_type"
|
||||
| "views.col.kind"
|
||||
| "views.col.location"
|
||||
| "views.col.project"
|
||||
| "views.col.rule"
|
||||
| "views.col.status"
|
||||
| "views.col.time"
|
||||
| "views.col.title"
|
||||
| "views.density.comfortable"
|
||||
| "views.density.compact"
|
||||
| "views.editor.cancel"
|
||||
| "views.editor.confirm_delete"
|
||||
| "views.editor.delete"
|
||||
| "views.editor.error.delete_failed"
|
||||
| "views.editor.error.load_failed"
|
||||
| "views.editor.error.name_required"
|
||||
| "views.editor.error.slug_format"
|
||||
| "views.editor.error.sources_required"
|
||||
| "views.editor.field.density"
|
||||
| "views.editor.field.horizon"
|
||||
| "views.editor.field.icon"
|
||||
| "views.editor.field.name"
|
||||
| "views.editor.field.personal_only"
|
||||
| "views.editor.field.scope_mode"
|
||||
| "views.editor.field.shape"
|
||||
| "views.editor.field.show_count"
|
||||
| "views.editor.field.slug"
|
||||
| "views.editor.heading.edit"
|
||||
| "views.editor.heading.new"
|
||||
| "views.editor.hint.slug"
|
||||
| "views.editor.hint.sources"
|
||||
| "views.editor.icon.bell"
|
||||
| "views.editor.icon.building"
|
||||
| "views.editor.icon.calendar"
|
||||
| "views.editor.icon.clock"
|
||||
| "views.editor.icon.default"
|
||||
| "views.editor.icon.folder"
|
||||
| "views.editor.icon.users"
|
||||
| "views.editor.save"
|
||||
| "views.editor.section.identity"
|
||||
| "views.editor.section.render"
|
||||
| "views.editor.section.scope"
|
||||
| "views.editor.section.sources"
|
||||
| "views.editor.section.time"
|
||||
| "views.editor.subtitle"
|
||||
| "views.editor.title"
|
||||
| "views.empty.title"
|
||||
| "views.error.back"
|
||||
| "views.error.network"
|
||||
| "views.error.not_found"
|
||||
| "views.heading"
|
||||
| "views.horizon.all"
|
||||
| "views.horizon.any"
|
||||
| "views.horizon.custom"
|
||||
| "views.horizon.next_30d"
|
||||
| "views.horizon.next_7d"
|
||||
| "views.horizon.next_90d"
|
||||
| "views.horizon.past_30d"
|
||||
| "views.horizon.past_90d"
|
||||
| "views.kind.appointment"
|
||||
| "views.kind.approval_request"
|
||||
| "views.kind.deadline"
|
||||
| "views.kind.project_event"
|
||||
| "views.loading"
|
||||
| "views.onboarding.body"
|
||||
| "views.onboarding.create"
|
||||
| "views.onboarding.title"
|
||||
| "views.save_as"
|
||||
| "views.scope.all_visible"
|
||||
| "views.scope.explicit"
|
||||
| "views.scope.my_subtree"
|
||||
| "views.scope.personal_only"
|
||||
| "views.shape.calendar"
|
||||
| "views.shape.cards"
|
||||
| "views.shape.list"
|
||||
| "views.source.appointment"
|
||||
| "views.source.approval_request"
|
||||
| "views.source.deadline"
|
||||
| "views.source.project_event"
|
||||
| "views.subtitle"
|
||||
| "views.title"
|
||||
| "views.toast.inaccessible_n"
|
||||
| "views.toast.inaccessible_one";
|
||||
|
||||
78
frontend/src/inbox.tsx
Normal file
78
frontend/src/inbox.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /inbox — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// The page is a thin shell around two host divs: one for the
|
||||
// <FilterBar> primitive and one for the result list. The bar takes
|
||||
// care of every axis (approval_viewer_role chip cluster replaces the
|
||||
// two-tab UI; status / entity_type / time chips are new affordances).
|
||||
// Rows render via shape-list.ts with row_action="approve" — the
|
||||
// inbox-specific markup that produces the diff + approve/reject/revoke
|
||||
// buttons. Action handlers are wired in client/inbox.ts.
|
||||
//
|
||||
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
|
||||
// to ?a_role=self_requested before the bar mounts so old bookmarks
|
||||
// (sidebar bell, Genehmigungen email links) keep landing on the
|
||||
// expected sub-view.
|
||||
|
||||
export function renderInbox(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="approvals.title">Genehmigungen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/inbox" />
|
||||
<BottomNav currentPath="/inbox" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="approvals.heading">Genehmigungen</h1>
|
||||
<p className="tool-subtitle" data-i18n="approvals.subtitle">
|
||||
4-Augen-Prüfung für Fristen und Termine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="inbox-filter-bar" />
|
||||
|
||||
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</div>
|
||||
<div className="entity-empty" id="inbox-empty" style="display:none" />
|
||||
<div id="inbox-results" />
|
||||
|
||||
{/* t-paliad-154 — admin-only nudge surfaced when:
|
||||
- the user is global_admin
|
||||
- the inbox is empty (no pending / mine)
|
||||
- no approval_policies row exists firm-wide
|
||||
Hidden in all other cases. Wires via /api/admin/approval-policies/seeded. */}
|
||||
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
|
||||
<h3 data-i18n="inbox.empty.admin_nudge.title">Noch keine Genehmigungspflichten konfiguriert?</h3>
|
||||
<p data-i18n="inbox.empty.admin_nudge.body">
|
||||
Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.
|
||||
</p>
|
||||
<a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin_nudge.cta">
|
||||
Genehmigungspflichten konfigurieren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
</main>
|
||||
|
||||
<script src="/assets/inbox.js" defer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user