m's call (2026-05-07 21:52): "remove the export variable, that is bad
form. It should be connected only to my account."
The PALIADIN_ENABLED env var was a deploy-time toggle: easy to
mis-flip, splits prod/dev behaviour, and reads as "could be turned on
for anyone." Replaced with a per-request gate in code:
services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
handlers/paliadin.go now gates every entry point through
requirePaliadinOwner, which looks up paliad.users.email by the caller's
UUID and returns 404 (not 403 — pretend the route doesn't exist) for
anyone else.
Routes register unconditionally; the gate is in the code, not the
deploy. main.go wires PaliadinService whenever DATABASE_URL is set and
logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED
row and gains an explanatory note about the in-code gate.
Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under
Admin) now render with display:none, revealed by sidebar.ts after
/api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL —
same fail-closed pattern the Admin group already uses.
Side-effect for ops: paliad.de production now serves the routes too,
but only to m, and only successfully if the host has tmux + claude
in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a
"tmux unavailable" — clear failure mode, not a security concern.
One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the
constant aligned with migration 023's seed so a future rename of m's
account doesn't silently strand the gate. All existing tests pass.
Phase 0 of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only laptop scope, gated behind PALIADIN_ENABLED=false on
prod. Lifts the goldi/mVoice tmux-Claude pattern (mVoice/server.py:
250-380) into a Go service: long-lived `claude` pane in a tmux
session, prompts in via `tmux send-keys -l`, responses out via a
per-turn file (/tmp/paliadin/{turn_id}.txt) the system prompt
instructs Claude to write.
What landed
-----------
- migration 058_paliadin_poc — paliad.paliadin_turns audit table
(full prompt + response stored at PoC scope; redaction returns
at production v1 per design §3.3). RLS: user sees own,
global_admin sees all.
- internal/services/paliadin.go — the orchestrator. ensurePane()
finds-or-creates the tagged tmux window, sendToPane sends the
framed [PALIADIN:turn_id] envelope, pollForResponse reads the
per-turn file, splitTrailer parses the [paliadin-meta] block
Claude appends to every reply (used_tools, rows_seen,
classifier_tag).
- internal/services/paliadin_prompt.go — the system prompt sent
once to a fresh Claude pane. Defines the response protocol
(Write-to-file + meta trailer), the action-chip marker syntax,
the visibility-gate rule (paliad.can_see_project required in
every project-scoped query), and 9 SQL recipes covering m's
paliad data + cross-schema youpc case-law lookup.
- internal/handlers/paliadin.go — POST /api/paliadin/turn kicks
off the work in a goroutine and returns an SSE URL; GET
/api/paliadin/stream/{id} relays per-turn channel events
(meta/content/end/error/ping) to EventSource. Routes register
ONLY when PaliadinService is wired — paliadinSvc nil → no
handlers exist, prod surface is clean.
- /admin/paliadin dashboard — global_admin-only. Shows total
turns, last-7-days, median/p90 duration, tool-use rate (the
load-bearing §0.5.7 metric), abandon rate, classifier
histogram, daily sparkline, top prompts, recent turn log.
Powered by PaliadinService.Stats() + ListRecentTurns().
- frontend: paliadin.tsx + client/paliadin.ts (chat panel with
starter prompts, EventSource consumer, typewriter render of
one-shot content blob, citation-chip parser, "Stop" + "New
conversation" buttons, localStorage history); admin-paliadin
pair (read-only stats dashboard).
- Sidebar: Paliadin entry under Übersicht (ICON_SPARKLE);
Paliadin Monitor under Admin.
- 36 i18n keys (DE+EN), CSS for chat panel + dashboard.
- main.go: PaliadinService wires only on PALIADIN_ENABLED=true,
with PALIADIN_TMUX_SESSION + PALIADIN_RESPONSE_DIR overrides.
Logs visibly so the operator can confirm at boot.
- CLAUDE.md: ANTHROPIC_API_KEY row updated (PoC doesn't need it
— Claude CLI uses m's subscription; key reserved for future
production-v1). New rows for the three PALIADIN_* env vars.
Tests
-----
- 7 unit tests on the trailer parser, chip counter, token approx,
and tmux-input sanitiser. All pass. The trailer parser is
load-bearing for monitoring; an unobserved parser bug = silent
dashboard rot.
What's NOT in v1 (stays deferred)
---------------------------------
- The Anthropic API client (production v1, gated on PoC success
per §0.5.7).
- BYO-AI / OpenAI adapter.
- Per-user rate limiting.
- Multi-replica SSE bus.
- Mascot / avatar SVG.
- Persistent threads (history is browser localStorage only).
How to use locally
------------------
$ export PALIADIN_ENABLED=true
$ ./paliad
# browse /paliadin → type a question → answers stream back
# /admin/paliadin shows the monitoring dashboard
Migration: 058 (skips fritz's t-147 on 057). Safe on prod
because PALIADIN_ENABLED defaults to false; the table is created
but no routes touch it until the env var flips.
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.
Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
template_key, recipient_filter jsonb, recipient_user_ids uuid[],
send_report jsonb, sent_at). RLS: senders read own rows, global_admin
reads all; inserts must self-attribute. No CHECK-constraint extension to
partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
persists audit row, dispatches via 5-deep goroutine pool with 15s
per-send timeout. Send report (sent/failed counts + per-recipient errors)
is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
*italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
first; only whitelisted tags re-emitted. Script tags and javascript:
URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
{{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
index. Powers the /team project multi-select filter without N round
trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
/admin/broadcasts is gateOnboarded (not adminGate) so leads can see
their own sends; the service applies the per-row visibility filter.
Frontend
- /team gains a project multi-select chip dropdown (visible projects
loaded from /api/projects, intersected against the memberships index)
alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
true (global_admin always; non-admin needs lead-ship on selected
projects, or at least one project when no filter is set). Server still
re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
template dropdown (loads existing email templates and strips Go-template
directives) + recipient preview (first 5 + expand) + send. Hard-blocks
empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
detail (subject, body, recipient list, send_report counts).
Tests
- broadcast_service_test.go: placeholder substitution table-driven,
Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
validation cases (empty subject/body, recipient cap, invalid email),
signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
rules (lead can send on own project, member cannot, admin sees all,
member can't read lead's row). Skips when TEST_DATABASE_URL is unset.
i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
Phase A2 of the data-display-model rethink. Builds on A1's API contract
(merged as cda4b40). User-visible.
What lands:
- TSX shells for /views (the view runner) and /views/new + /views/{slug}/edit
(the editor). One TSX per page; client/views.ts + views-editor.ts
hydrate.
- Three render-shape components in client/views/: shape-list.ts (table
for density=comfortable, compact one-line stream for density=compact —
the activity-feed look without a separate "activity" shape per Q4 lock-
in 2026-05-07), shape-cards.ts (day-grouped chronological), and
shape-calendar.ts (month grid with day-pills, mobile cards-fallback
notice on viewports <600px per design §9 trade-off 8).
- Generic view shell that resolves a slug to a system view (via
/api/views/system) or a user view (via /api/user-views), runs it via
POST /api/views/{slug}/run, dispatches to the matching shape, exposes
a 3-button shape switcher that swaps the live render without re-fetching,
and surfaces the inaccessible-projects toast when the substrate flags
some IDs (Q17 fail-open attribution).
- View editor with widgets for name/slug/icon, sources (4 checkboxes),
scope mode (all_visible / my_subtree / personal_only), time horizon
(six fixed options), shape, and list density. Slug regex enforced
client-side mirroring the server validator. Save → POST/PATCH; delete
→ simple yes/no confirm (Q25 lock-in).
- Sidebar "Meine Sichten" group between Arbeit and Werkzeuge. Renders
empty server-side; client/sidebar.ts.initUserViewsGroup() hydrates from
GET /api/user-views on mount, injecting one nav item per saved view
+ an always-present "+ Neue Sicht" trailing entry. show_count=true
views get a sidebar badge updated by a fire-and-forget run query.
- Page handlers /views (most-recently-used redirect or onboarding shell),
/views/{slug}, /views/new, /views/{slug}/edit. All gateOnboarded.
- 91 new i18n keys (DE+EN) covering nav.group.user_views, view shell,
shape labels, source/kind/horizon/scope vocabulary, editor form,
empty/error/onboarding states.
- ~250 lines of CSS for the views shell, list/cards/calendar shapes,
Meine Sichten sidebar group.
- build.ts registers views.tsx + views-editor.tsx page renderers and
the two client bundles.
Frontend builds clean (i18n codegen 1700→1791 keys), backend builds +
vets clean, all tests pass, IIFE wrap intact on the new bundles.
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.
Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.
Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
LEFT JOIN paliad.projects to surface project_title for the Verlauf
attribution chip on /projects/{id}. Inner subquery aliased pp to
avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
enrichment. Other readers leave it nil and the JSON serialiser omits
it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
?direct_only=true|false and passes through to the service. New
handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
DeadlineService.ListForProject + AppointmentService.ListForProject
+ ProjectService.ListEvents (live-DB test, skipped without
TEST_DATABASE_URL).
Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
/api/projects/{id}/deadlines + /appointments (legacy narrow) to
/api/events?type=deadline|appointment&project_id={id} (the union
endpoints, already aggregating + enriching with project_title). The
Verlauf still uses /api/projects/{id}/events but with the new
direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
subtree (true). persistSubtreeMode replaceState keeps back-button
friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
Appointments sections. Shared state across the three; clicking any
toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
.aggregation-chip — small additive styling.
No schema. No migration. Phases 2 + 3 stack on top per design §7.
Commit 5 of 8. End-user surface for the approval workflow:
- /inbox page (frontend/src/inbox.tsx + client/inbox.ts) with two tabs:
"Zur Genehmigung" (requests I qualify to approve) and "Meine
Anfragen" (requests I submitted). Each row shows the project, entity
title, lifecycle event, requester name + age, the date-field diff
(for update/complete/delete) and the relevant action buttons:
approve + reject when on pending-mine, revoke when on mine.
Historic rows render a status pill instead of buttons.
- Sidebar bell entry "Genehmigungen" (with sidebar-inbox-badge) under
the Übersicht group. sidebar.ts polls /api/inbox/count every 60s and
shows the count (or 9+ ceiling) when > 0.
- Server registration: GET /inbox → dist/inbox.html, gated by
gateOnboarded. Already-registered API endpoints (commit 4) handle
the data path.
- Bilingual (DE primary / EN secondary) i18n strings under
approvals.* — labels, status names, lifecycle names, role names,
decision-kind names, action verbs, error messages. ~50 new keys.
- Pending-state CSS classes: .approval-pill, .approval-pill--historic,
.entity-row--pending-{create,update,complete,delete},
#sidebar-inbox-badge. Soft-tint rows + amber pill so an approver
can scan a list of pending entities at a glance. Used by commit 6
(pending pills across surfaces) — no other surface picks them up
yet, but the styles are wired and ready.
- Sidebar.tsx navItem signature gains an optional badgeID parameter
so any future sidebar entry can host a count-badge with one extra
argument (no per-entry custom rendering).
Combined backend API for the upcoming policy-authoring page (commit 4)
and inbox + bell (commit 5). Registers:
Policy CRUD (admin-only via RequireAdminFunc gate):
- GET /api/projects/{id}/approval-policies
- PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
- DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
Inbox (any authenticated user; service-layer query gates by visibility
+ role-tier match):
- GET /api/inbox/pending-mine — requests I qualify to approve
- GET /api/inbox/mine — requests I submitted
- GET /api/inbox/count — bell badge count
- GET /api/approval-requests/{id} — one hydrated request
Decisions (caller authorization checked at service layer; the CHECK
constraint on approval_requests blocks self-approval as a second
defence):
- POST /api/approval-requests/{id}/approve
- POST /api/approval-requests/{id}/reject
- POST /api/approval-requests/{id}/revoke
Error mapping (writeApprovalError):
- ErrSelfApproval → 403 self_approval_blocked
- ErrNoQualifiedApprover → 409 no_qualified_approver
- ErrConcurrentPending → 409 concurrent_pending
- ErrNotApprover → 403 not_authorized
- ErrRequestNotPending → 409 request_not_pending
Frontend pages (the policy authoring tab on /projects/{id}/settings
and the /inbox page with bell) follow in subsequent commits — the
endpoints are usable via curl + admin tooling immediately.
Commit 2 of 8 — the workflow engine for the 4-Augen-Prüfung. Wires the
service into the handlers.Services bundle so commit 3 can call into
SubmitCreate/Update/Complete/Delete from DeadlineService and
AppointmentService.
Public surface:
- Submit{Create,Update,Complete,Delete} — invoked by Deadline /
AppointmentService inside their existing tx. Looks up policy,
runs the deadlock check, inserts paliad.approval_requests, marks
the entity pending, emits the *_approval_requested project_events
audit row.
- Approve / Reject / Revoke — top-level operations (own tx). Approve
finalises the lifecycle (clears pending markers + sets approved_by
for non-delete; hard-deletes for delete). Reject / Revoke revert
the entity from pre_image (delete a pending-create, restore date
fields, NULL completed_at).
- ListPendingForApprover / ListSubmittedByUser / GetRequest /
PendingCountForUser — read paths the inbox + bell will hit in
commit 5.
- ListPolicies / UpsertPolicy / DeletePolicy — CRUD for the
authoring page in commit 4.
Self-approval is blocked at three layers:
1. canApprove() returns ErrSelfApproval when caller == requester.
2. The DB CHECK constraint approval_requests_no_self_approval.
3. The deadlock check excludes the requester from the pool.
Strict-ladder helper levelOf(role) mirrors the SQL function added in
migration 054. Path-walk authorization: ancestors with eligible roles
qualify for descendant requests (matches the visibility predicate).
Tests:
- Pure-Go: levelOf strict-ladder semantics, IsValidRequiredRole,
approvalEventType. All pass under `go test`.
- Live-DB (TEST_DATABASE_URL): no-policy noop; submit→approve cycle;
reject-create deletes; reject-update restores pre_image;
no-qualified-approver fail; revoke flow; policy CRUD roundtrip.
Skipped when TEST_DATABASE_URL is unset, mirroring the existing
audit_service_test pattern.
No call sites in DeadlineService / AppointmentService yet — that's
commit 3. Paliad continues to behave identically until that lands.
GET /api/tools/courts[?courtType=UPC-LD] returns the deadline-
computation slice of paliad.courts (id, code, names, country, regime,
court_type) — distinct from the rich Gerichtsverzeichnis at
/api/courts. Optional courtType filter narrows to a single tier.
POST /api/tools/fristenrechner and POST /api/tools/fristenrechner/
calculate-rule both accept an optional courtId field. When set, the
calculator resolves the court's (country, regime) and uses that
calendar; when omitted, the proceeding's existing jurisdiction column
seeds a sensible default — preserves today's behaviour for callers
that don't yet send a court.
Frontend: court-picker-row added to step 2 of the Fristenrechner
wizard. Visible only for proceeding types with multiple compatible
courts (today: every UPC-flavoured proceeding — UPC LDs span 12
countries, plus UPC CD seats and the CoA). DE-only proceedings (BPatG
nullity, BGH appeals, DPMA, EPA, EP grant) keep the form unchanged.
Picker re-runs the calc on selection so the user sees the same
deadlines shift to a different calendar without a manual click. i18n
key deadlines.court.label added for both DE and EN.
Default courts wired sensibly: UPC_INF / UPC_REV / UPC_PI etc. → UPC
LD München (HLC's home venue); UPC_APP / UPC_APP_ORDERS /
UPC_COST_APPEAL → UPC CoA Luxembourg; UPC_REV → UPC CD Paris.
Holiday struct gains Country (ISO-3166) + Regime ('UPC' | 'EPO' | "")
fields. AppliesTo(country, regime) is the matching rule the new lookup
methods filter through: a row matches when its Country equals the
court's country OR its Regime equals the court's regime. UPC LD München
(DE+UPC) sees DE federal + UPC vacations; LG München (DE+"") sees only
DE federal; UPC LD Paris (FR+UPC) sees FR + UPC. germanFederalHolidays
fallback now country-tagged 'DE' so the per-country filter applies it
only to DE-jurisdictional callers.
Public service methods (IsHoliday, IsNonWorkingDay, AdjustForNonWorking
Days, AdjustForNonWorkingDaysWithReason, findVacationBlock) all take
(country, regime). Cache stays year-keyed — single DB hit per year, all
courts touching that year share it.
New CourtService loads paliad.courts once + answers Lookup(id),
CountryRegime(id, defaultCountry, defaultRegime), All(), ByCourtType(t).
FristenrechnerService.CalcOptions / CalcRuleParams gain CourtID;
EventDeadlineService.Calculate gains courtID. When courtID is empty,
DefaultsForJurisdiction maps the proceeding's existing jurisdiction
column to a sensible (country, regime) default — UPC proceedings get
(DE, UPC), everything else gets DE-only — preserving today's behaviour
for callers that don't yet send a court.
Tests: new TestAppliesTo_CountryRegimeFilter + TestAppliesTo_Rules
cover the cross-product of (DE court / UPC LD München / UPC LD Paris /
LG München) × (DE federal / UPC vacation / FR holiday). Existing tests
threaded through with ('DE', 'UPC') to preserve behaviour they were
written to lock.
The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.
Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
optional condition flags. Returns rule metadata + computed dueDate +
originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
+ HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
Sommerferien skip both apply automatically. Court-determined rules
(party='court' or event_type ∈ hearing/decision/order) return
IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
hasn't supplied the flag — the UI uses this to render checkboxes;
toggling recomputes live. With all flags satisfied + alt_duration_*
present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
and error paths (skipped without TEST_DATABASE_URL).
Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
card (only one open at a time). Pill picker (radio chips) appears
when the card has 2+ rule pills; first preselected. Trigger date
defaults to today (m's Q3). Flag checkboxes auto-render from the
rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
/api/projects/{id}/deadlines/bulk with a single-element array using
source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
(Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
have a single rule to compute.
- Escape collapses the open card.
CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.
UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
Backend layer for the v3 decision tree:
- internal/services/event_category_service.go (NEW)
- Tree(): nested tree of all active event_categories for the
Pathway B / B1 cascade UI. Uses single SELECT + in-memory
parent-child stitching; corpus is small (≤100 nodes).
- ConceptsForSlug(): recursive CTE walks descendants of a slug and
joins event_category_concepts to return the candidate concept
outcomes (with optional proceeding_type_code narrowing).
- ConceptIDsForSlug(): convenience reduction for
`WHERE concept_id = ANY(...)` queries against the existing
deadline_search matview.
- ProceedingCodesForSlug(): per-leaf proceeding-code narrowing for
Phase D's forum filter intersection.
- internal/handlers/fristenrechner_event_categories.go (NEW)
- GET /api/tools/fristenrechner/event-categories returning the
nested tree as JSON. Frontend will ETag-cache via localStorage.
- Wired EventCategory into handlers.Services + dbServices + main.go.
The existing /api/tools/fristenrechner/search handler stays
unchanged in this commit; Phase D will add ?event_category_slug=
and ?forum= query params on top.
Build + vet clean.
Closes the search half of the unified Fristenrechner. Phase D (concept-card
UI on /tools/fristenrechner) follows in a subsequent shift.
Migration 047:
- Seed the missing `wiedereinsetzung` concept and re-point the four
Wiedereinsetzung trigger_events (200..203) at it. PR-7 referenced
the slug `re-establishment-of-rights` but never seeded the concept,
so the four cross-cutting triggers were dropping out of any concept-
JOINing query. Per m's slug rule (Q1: shared cross-cutting concepts
use DE slug because German term dominates HLC vocabulary).
- Create paliad.deadline_search materialised view: UNION ALL of
(deadline_rules joined to deadline_concepts) and (trigger_events
joined to deadline_concepts via slug). Trigram GIN indexes on
legal_source / concept_name_de / concept_name_en / rule_name_de /
rule_name_en / rule_code; gin (concept_aliases) for array
containment; UNIQUE INDEX on a synthetic row_key so refresh can
run CONCURRENTLY.
Refresh strategy: data only mutates via migration files at server
startup, so no AFTER triggers and no pg_cron — main.go calls
services.RefreshSearchView right after db.ApplyMigrations. CONCURRENTLY
keeps reads online and stays well under 100 ms at < 1k rows.
Service `internal/services/deadline_search_service.go`:
- Two-query pipeline per request: (1) rank concept_ids by
GREATEST(similarity()) across name / aliases / legal_source / rule_code
plus a 0.2 alias-hit boost; (2) load all matview rows for the top-N
concepts and assemble per-pill JSON.
- normalizeQuery strips legal-prefix noise (`§`, `Art.`, `Section`,
`Rule `) so users typing `§ 82` find DE.PatG.82.1 even though the
structured legal_source column doesn't carry the prefix.
- FormatLegalSourceDisplay renders structured codes back to the
pleading form HLC users expect:
UPC.RoP.23.1 → "UPC RoP R.23(1)"
DE.PatG.82.1 → "PatG §82(1)"
EU.EPÜ.108 → "EPÜ Art.108"
EU.EPC-R.79.1 → "EPC R.79(1)"
EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
- Drill URLs route per kind: rule pills → ?proc=…&focus=…, trigger
pills → ?mode=event&triggerId=…
Handler `GET /api/tools/fristenrechner/search?q=&party=&proc=&source=&limit=`:
- Returns the JSON shape from design §6.1 (cards-with-pills).
- 503 with friendly DE message when DATABASE_URL is unset, mirroring
the other Fristenrechner endpoints.
- Empty q returns an empty cards array (browse surface is Phase D).
Tests:
- Pure-Go: TestFormatLegalSourceDisplay (12 cases across all known
prefixes) + TestNormalizeQuery (8 cases).
- Integration (skipped without TEST_DATABASE_URL): golden table
pinning the design's binding queries — Klageerwiderung returns the
statement-of-defence card with UPC.RoP.23.1, DE.ZPO.276.1,
DE.PatG.82.1, EU.EPC-R.79.1, DE.PatG.59.3 pills; "RoP 23" returns
the same card; "§ 82" → normalized "82" → BPatG hit; Wiedereinsetzung
returns one card with exactly 4 trigger pills (ids 200..203);
party / source filters narrow as expected; limit cap honoured.
- SQL semantics validated against live data via supabase MCP using a
CTE-inlined matview definition with the slug fix simulated; results
match the golden table.
Per design doc `docs/plans/unified-fristenrechner.md` §4.6 (matview
shape) + §6 (search ranking + API).
PR-2 of t-paliad-115. The unified Fristen + Termine surface now lives at
/events. Old /deadlines and /appointments list URLs 301-redirect to
/events?type=deadline and /events?type=appointment so existing bookmarks
still land on the right view. Detail pages (/deadlines/{id},
/appointments/{id}) stay type-specific.
Backend (Go).
- New `GET /events` route → handleEventsListPage serves dist/events.html.
- `GET /deadlines` → handleDeadlinesListRedirect (301 → /events?type=deadline).
- `GET /appointments` → handleAppointmentsListRedirect (301 → /events?type=appointment).
- /deadlines/new, /deadlines/calendar, /deadlines/{id}, /appointments/new,
/appointments/calendar, /appointments/{id} unchanged — type-specific
detail / form / legacy-calendar surfaces stay where they are.
Frontend.
- build.ts now emits ONE events.html (not events-deadlines /
events-appointments) with defaultType="all" baked in. The page reads
?type=… and ?view=… on hydration, so /events?type=deadline lands on
the Fristen-only Cards view, /events?view=calendar opens the calendar,
and bare /events shows the Beides view.
- Sidebar Fristen / Termine entries point at /events?type=deadline and
/events?type=appointment. The SSR active-state matches exactly via
href === currentPath, so detail/new/calendar pages that pass
currentPath="/events?type=deadline" (resp. appointment) still
highlight the right entry.
- events.ts hydration adds applySidebarTypeHighlight(): on bare /events
the sidebar SSRs with neither entry lit, and we re-highlight the
matching entry whenever the in-page chip toggle changes the active
type. Sidebar stays in sync without a server round-trip.
- Updated every list-target reference: palette-actions.ts (Cmd-K
navigation), deadlines-detail.ts + appointments-detail.ts (post-delete
redirect), and the back-link / cancel hrefs in the *-new + *-detail +
*-calendar TSX templates. Detail-page Sidebar/BottomNav currentPath
also moved from "/deadlines" → "/events?type=deadline" so the new
highlight contract holds end-to-end.
Out of scope (per task brief).
- A third "Ereignisse / Alle Events" sidebar entry pointing at /events
bare. m's call: keep two entries; defer until signal.
- Removing /deadlines/calendar + /appointments/calendar standalone
pages. The new /events?view=calendar covers the same need but the
legacy URLs stay live for one cycle.
Build clean: `cd frontend && bun run build` + `go build/vet/test ./...`.
PR-1 of the Fristen+Termine unification (t-paliad-110). Backend layer
only — no frontend changes; the existing /deadlines and /appointments
pages still render the type-specific UIs.
EventService delegates to DeadlineService + AppointmentService for the
actual reads (no duplicate visibility logic, no duplicate event_type
hydration), then projects both into the discriminated EventListItem
union and merges/sorts by event_date asc. The handler exposes:
GET /api/events?type=deadline|appointment|all&status=…&project_id=…
&event_type=…&type_filter=…&from=…&to=…
GET /api/events/summary?type=…&project_id=…
Bucket model (per t-paliad-110 spec, supersedes t-106):
- four universal cards: Heute · Diese Woche · Nächste Woche · Später
- Überfällig is deadline-only, conditional, alarm-styled when > 0
- Erledigt drops from the card row; stays available as a filter option
- appointments have no completed_at — past appointments aren't bucketed
The deadline-side cutoffs reuse computeDeadlineBucketBounds so
/api/events/summary and /api/deadlines/summary can never disagree.
Existing /api/deadlines and /api/appointments stay untouched —
calendars, project-detail panes, and CalDAV consumers still call them
directly.
Two related checklist UX gaps:
1. Checklist events in a project's Verlauf tab were unclickable — and
nothing in the project_events row carried the originating instance ID.
Add an `insertProjectEventWithMeta` helper, write
{"checklist_instance_id": <uuid>} as project_events.metadata for
checklist_created / _renamed / _linked / _unlinked / _reset (skipped
for _deleted — instance is gone). Surface metadata on
/api/projects/{id}/events and on dashboard recent_activity. The
Verlauf renderer wraps the title in <a href="/checklists/instances/{id}">
when metadata.checklist_instance_id is present, and the dashboard's
activity feed deep-links the project ref to the instance directly for
checklist_* events. Existing rows (metadata `{}`) stay non-clickable —
no migration backfill needed.
2. /checklists previously demanded a template pick before any existing
instance was reachable. Add a tab nav (Vorlagen / Vorhandene Instanzen)
using the existing entity-tab pattern. New endpoint
GET /api/checklist-instances and ChecklistInstanceService.ListAllVisible
return every visible instance across templates + projects, joined with
project ref/title and sorted by created_at DESC. Rows show template,
instance name (linked), project link (or "Persönlich"), progress bar,
and created date. URL state (?tab=instances) keeps the active tab
shareable. EN + DE i18n covered for tab labels and column headers.
Also adds event.title.checklist_* localizations for the Verlauf header
that translateEvent looks up.
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.
Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
global.css
Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
Q6 of t-paliad-088 left firm-wide event_type creation open to any user; this
ships the moderation surface admins use to dedupe and clean up the resulting
drift.
Service layer (internal/services/event_type_service.go):
- ListAllForAdmin(filter) — firm-wide rows with usage_count and
author_display_name, optionally including archived (single query, scalar
subquery + LEFT JOIN paliad.users). Sorted live-first, then category +
label_de.
- ListPrivatePendingPromotion — every private non-archived row across all
users, sorted by usage_count DESC.
- ArchiveBulk(ids) — UPDATE archived_at=now() WHERE is_firm_wide AND NULL.
- Promote(id) — flip is_firm_wide=true; surfaces ErrEventTypeSlugTaken on
collision so the admin can merge instead.
- Restore(id) — flip archived_at back to NULL; same slug-collision surface.
- MergeIDs(winner, losers) — tx-scoped INSERT … SELECT … ON CONFLICT
redirect of deadline_event_types from losers → winner, then DELETE on the
loser junction rows, then archive the losers. Refuses if the winner is
archived or private. Junction PK does the dedup.
- requireAdmin gate runs at every method (defence-in-depth on top of the
handler-level RequireAdminFunc).
Handlers (internal/handlers/admin_event_types.go):
- GET /api/admin/event-types[?include_archived=1]
- GET /api/admin/event-types/private
- POST /api/admin/event-types/archive { ids:[…] }
- POST /api/admin/event-types/merge { winner_id, loser_ids:[…] }
- POST /api/admin/event-types/{id}/promote
- POST /api/admin/event-types/{id}/restore
- GET /admin/event-types page shell.
All wrapped behind auth.RequireAdminFunc at registration time.
Frontend:
- New /admin/event-types SPA (admin-event-types.tsx + client/admin-event-types.ts):
search, "Archivierte anzeigen" toggle, per-row archive/restore, bulk
archive, merge modal (winner picker defaults to highest-usage row),
separate table for private types pending promotion.
- Sidebar entry under Verwaltung; admin landing card.
- ~50 i18n keys DE+EN under admin.event_types.* + nav.admin.event_types.
- CSS for archived badge, merge option list, bulk-actions bar.
Out of scope (deferred): public "merge request" workflow for non-admins.
Migration 030 adds paliad.event_types and paliad.deadline_event_types
junction. ~43 firm-wide seeds biased toward submissions (25 UPC
submissions + 8 UPC decisions/orders/hearings + 5 EPO + 4 DPMA/DE + 1
cross-jurisdiction). UPC-seeded rows carry a loose trigger_event_id
column (no FK constraint per Q2: event_types leads, trigger_events
follows). RLS policies are defense-in-depth — primary enforcement is
in the Go service layer. Per Q6, any authenticated user can create
firm-wide types; admins moderate via the soft-delete archive lever.
EventTypeService: List (firm-wide ∪ own-private), GetByID, Create
(slug auto-derived, supports diacritics → ASCII), Update (author OR
admin-on-firm-wide), SuggestSimilar (powers the duplicate-warning in
the add modal), AttachToDeadlineTx + ValidateForUser + ListForDeadlines
for the junction.
DeadlineService gains an EventTypeService dependency and now:
- accepts event_type_ids on Create / Update / CreateBulk
- attaches them in the same transaction as the deadline insert
- hydrates EventTypeIDs on every Get / List / ListForProject
- supports the multi-select Typ filter via ListFilter.EventTypeIDs +
IncludeUntyped (UNION semantics within types, AND-intersected with
Status/Project)
AgendaService gets the same Typ filter on its deadline side;
appointments are unaffected.
API:
- GET /api/event-types?category=&jurisdiction=
- GET /api/event-types/suggest?q=
- POST /api/event-types
- PATCH /api/event-types/{id} (set archive=true to hide)
- GET /api/deadlines?event_type=<uuid>,<uuid>,none
- GET /api/agenda?event_type=<uuid>,<uuid>,none
- POST/PATCH /api/deadlines accept event_type_ids: [uuid]
go build / go vet / go test ./... clean.
Frontend (picker + custom-add modal + multi-select filter) follows in
PR-2. Admin moderation panel deferred to t-paliad-089 follow-up.
Adds the second Fristenrechner mode (mirrored from youpc.org's deadline
calc): pick a UPC trigger event + date, see all deadlines that flow
from it. Coexists with the existing course-of-proceedings timeline mode
via a tab toggle on /tools/fristenrechner.
Backend:
- internal/services/event_deadline_service.go — EventDeadlineService.
ListTriggerEvents (alphabetical), Calculate (resolves all deadlines
flowing from a trigger). Routes through HolidayService for weekend +
holiday rollover. Honours the new working_days unit. Resolves
composite rules (alt_* + combine_op) by computing both legs and
picking max/min. Used by R.198/R.213 ("31d OR 20wd, whichever is
longer") imported in PR-1.
- internal/services/event_deadline_service_test.go — covers
addWorkingDays (forward, backward, zero, holiday-skip), composite
rule semantics, before-timing.
- internal/handlers/fristenrechner.go — two new endpoints:
GET /api/tools/trigger-events, POST /api/tools/event-deadlines.
- handlers.Services / dbServices: new EventDeadline / eventDeadline
field; wired in cmd/server/main.go from the same HolidayService.
Frontend:
- frontend/src/fristenrechner.tsx — tab strip + second wizard panel
(3 steps: trigger picker → date → flat result list).
- frontend/src/client/fristenrechner.ts — initEventMode wiring,
typeahead filter over the 102 trigger events, Calculate flow,
bilingual rendering, composite-rule labels, lang-change refresh.
- frontend/src/client/i18n.ts — 27 new keys (DE+EN) under
deadlines.mode.* and deadlines.event.* (incl. units, timing).
- frontend/src/styles/global.css — fristen-mode-tabs, mode-panel,
event-list, event-result-row visual style.
Working-day arithmetic detail: the new addWorkingDays helper steps
one day at a time and skips runs of non-working days (Sat/Sun + DE
federal + UPC vacations seeded via paliad.holidays). Day-zero is the
caller's job — addWorkingDays(0) returns the input unchanged so
callers can decide whether to roll forward via AdjustForNonWorkingDays.
Composite-rule resolution: when a row carries alt_duration_value +
alt_duration_unit + combine_op, Calculate computes both legs,
picks max/min, and surfaces a compositeNote like
"max(31 days, 20 working_days) → working_days leg" so the UI can
explain which leg won.
PR-3 will land Tier 1 bug fixes from the audit (CCR adaptive,
UPC_APP grounds anchoring, EP_GRANT priority, rule-code normalisation).
The suggestion + feedback handlers wrote to legacy public-schema tables
(`patholo_link_suggestions`, `patholo_link_feedback`) via Supabase PostgREST.
The patHoLo→Paliad rebrand moved those tables into the paliad schema as
`paliad.link_suggestions` / `paliad.link_feedback` — PostgREST is not
configured to expose paliad on the youpc Supabase, so all three callsites
500'd in prod.
Replace the PostgREST integration with a new LinkService backed by the same
sqlx pool every other paliad service uses. Schema-qualified table names
work directly via DATABASE_URL, the inconsistent supabaseInsert/Count
helpers go away, and the suggestion/feedback handlers now use requireDB
for clean 503s when the pool isn't wired.
handleSuggestionCount keeps its tolerant 0-on-error behaviour so the admin
badge never blocks page render. When DATABASE_URL is unset the count
endpoint returns 0 instead of 503 — knowledge-platform-only deployments
still serve the Link Hub page.
Flagged in t-paliad-074 (F-12).
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.
Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
language file when no DB row, Save validates parse + structural
invariants and writes a version, Reset deletes the active row, Restore
copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
back to the embedded default if the active row is malformed at parse
time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
text/template strings stored in the (key, lang) row. Default subjects
ship with a {{/* keep this phrasing */}} comment pointing at the
reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
+ .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
RequireAdminFunc(users) admin middleware, same shape as /admin/team.
Frontend:
- /admin/email-templates list page — three cards (one per template),
each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
textarea + variable docs + actions on the left, sandboxed iframe
preview + version log on the right. 500 ms debounced live preview;
save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.
Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.
Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean
Design: docs/design-email-templates-2026-04-29.md.
Backend rename (frontend lands in next commit):
- Migration 026: rename paliad.departments → paliad.partner_units,
paliad.department_members → paliad.partner_unit_members, junction FK
department_id → partner_unit_id, plus all constraints/indexes/policies.
Pre-drop seed re-runs migration 019's logic to capture any users.dezernat
drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table
with RLS (any-authenticated read, global_admin write).
- models.User.Dezernat dropped. Department / DepartmentMember →
PartnerUnit / PartnerUnitMember.
- DepartmentService → PartnerUnitService (file renamed via git mv to
preserve blame). Every mutation now opens a tx and emits a
partner_unit_events row in the same tx (created/updated/deleted/
member_added/member_removed). Update emits before/after snapshots;
Delete emits BEFORE the cascade so the FK still resolves, then
ON DELETE SET NULL keeps the historical row.
- /api/departments/* → /api/partner-units/*. Handlers renamed.
- New /admin/partner-units page handler stub.
- AuditService UNIONs the new partner_unit_events source as a 4th
branch; handler accepts AuditSourcePartnerUnitEvents.
- user_service: drop dezernat from CreateUserInput / UpdateProfileInput
/ AdminCreateInput / AdminUpdateInput. CreateUserInput gains
PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and
the membership row + audit event are inserted in the same tx.
- Settings tab aliases drop dezernat/department.
- Legacy /dezernate and /departments now redirect to
/admin/partner-units (admins only see it; non-admins land on the
forbidden bounce).
go build / vet / test compile clean.
Replaces the "Geplant: Audit-Log" placeholder on /admin with a working
viewer that unions paliad.project_events + caldav_sync_log + reminder_log
into a single keyset-paginated timeline.
- AuditService.ListEntries (internal/services/audit_service.go) does one
UNION ALL across the three sources, projecting each into a unified
AuditEntry shape and ordering by (timestamp, id) DESC. Cursor is
(BeforeTS, BeforeID) — matches the project-event Verlauf pattern. ILIKE
search escapes %/_ so "100%" doesn't act as a wildcard.
- GET /api/audit-log (internal/handlers/audit.go) accepts
source/from/to/q/before_ts/before_id/limit, validates the cursor halves
are paired, and returns { entries, next_cursor }. Both API and the
GET /admin/audit-log SPA shell are wrapped in auth.RequireAdminFunc, so
non-admins get 403 (API) / 302 (browser) via the same gate /admin/team
uses.
- Frontend (admin-audit-log.tsx + client/admin-audit-log.ts) renders the
table with source dropdown, range presets (24h / 7d / 30d / custom /
all), free-text search (debounced 250ms), and "Weitere laden" cursor
pagination. project_events rows reuse translateEvent (t-paliad-067 PR-1)
for DE/EN narrative parity with the dashboard activity feed; caldav and
reminder rows have their own per-event-type i18n keys.
- /admin landing card moved from PLANNED to AVAILABLE; sidebar admin
group gains a third entry.
Four standalone bugs from the 2026-04-27 polish audit (PR-E batch).
F-02 — /admin/team search input: long placeholder ("Nach Name oder
E-Mail suchen…") visually overlapped the absolutely-positioned count
badge ("31 / 31") because .glossar-search reserved only 0.75rem of
right padding. Bumped padding-right to 4.5rem so the badge sits in its
own gutter — same fix protects every other use of the .glossar-search
shell (admin team, glossary, etc.) without touching individual pages.
F-03 — /api/departments?include=members 500 regression. Migration 020
renamed paliad.dezernat_mitglieder → department_members but missed the
dezernat_id column on prod youpc. Application code (DepartmentService.
ListWithMembers / ListMembers / AddMember / RemoveMember) selects
department_id, which doesn't exist there → "column does not exist"
500. New migration 024 renames the column idempotently, plus the
indexes/constraints/policies that postgres did not auto-rename when
their table was renamed (departments_pkey, departments_office_idx,
departments_lead_idx, departments_lead_user_id_fkey,
departments_office_check, department_members_pkey,
department_members_user_idx, department_members_department_id_fkey,
department_members_user_id_fkey, departments_select / _write,
department_members_select / _write). Every rename uses a DO block that
swallows undefined_object / undefined_column so the migration is a
no-op on dev DBs that already had English names from migration 018.
Down step puts the German names back symmetrically.
F-08 — Project detail tabs (/projects/{id}/Verlauf|Team|…) used
href="#", so middle-click and "open in new tab" were broken even
though the SPA already mirrored the canonical path via
history.replaceState. initTabs() now sets each tab anchor's href to
/projects/{id}/{tab} (id resolved from the URL when the project hasn't
loaded yet) and only intercepts plain left-clicks — middle/ctrl/meta/
shift/alt fall through to the browser. Backend gains the previously-
missing /projects/{id}/history and /projects/{id}/children server
routes (both bound to handleProjectsDetailPage like every other tab),
so opening the URL in a fresh tab no longer 404s.
F-09 — /projects?view=tree was silently ignored: viewMode was hard-
coded to "flat" and the URL was never read. parseInitialView() now
seeds viewMode from ?view=, initFilters() syncs the dropdown to the
parsed value before binding the change handler, and changing the
dropdown rewrites the query string via history.replaceState (default
"flat" stays implicit to keep the canonical path clean). Bookmarks,
dashboard links, and copy-shared URLs round-trip correctly.
Verification:
- /api/departments?include=members live-tested after applying 024 to
youpc: returns 200 with members enriched.
- go build ./... + go vet ./... + go test ./... clean.
- bun run build clean.
`/admin` was 404 — only `/admin/team` existed. Add a browseable index so
the admin area has a root, with the existing Team-Verwaltung tile alongside
greyed-out roadmap placeholders (Departments, Audit-Log, Email-Templates,
Feature-Flags) so admins see what's coming.
- internal/handlers/admin_users.go: handleAdminIndexPage serves
dist/admin.html. Same RequireAdminFunc gate as /admin/team — non-admins
get the standard 302 to /dashboard?forbidden=admin.
- internal/handlers/handlers.go: register GET /admin under the existing
admin-conditional block.
- frontend/src/admin.tsx + client/admin.ts: card grid built from the
shared .grid + .card landing-page pattern. .admin-card-soon dims the
placeholders + adds a "Kommt bald" badge so they read as roadmap, not
broken links.
- frontend/src/components/Sidebar.tsx: add Admin-Bereich (/admin) above
Team-Verwaltung in the existing admin group. Both items live in the
same display:none group that sidebar.ts reveals after /api/me confirms
global_role='global_admin'.
- frontend/src/client/i18n.ts: nav.admin.bereich + admin.title /
.heading / .subtitle / .section.{available,planned} / .coming_soon
plus per-card title+desc, DE+EN.
- frontend/src/styles/global.css: .admin-section-planned spacing,
.admin-card-soon dimming, .admin-soon-badge pill.
- frontend/build.ts: register the renderAdmin entrypoint and admin.ts
client bundle.
- New auth.RequireAdmin middleware (gates by paliad.users.role='admin')
with API/browser-aware reject paths and a fail-closed lookup-error 500.
- Service: AdminCreateUser (onboard from existing auth.users), AdminUpdate
(full profile fields incl. additional_offices), AdminDeleteUser (also
removes project_teams + department_members memberships and clears any
led-Dezernat seat — auth.users is left intact), ListUnonboardedAuthUsers,
IsAdmin (implements auth.AdminLookup).
- Handlers: GET/POST /api/admin/users, GET /api/admin/users/unonboarded,
PATCH/DELETE /api/admin/users/{id}, plus GET /admin/team for the page.
All registered through RequireAdminFunc so non-admins get 403/302.
- Refuses to delete the last remaining admin and rejects role='admin'
assignment via the admin UI (still SQL-only) — same rules as PATCH /api/me.
- /admin/team page: full users table with inline edit (display_name, office,
role, dezernat, additional_offices, lang), trash with confirm, search +
office filters, "Onboard existing account" modal driven by
/api/admin/users/unonboarded, and an Invite button that re-opens the
shared sidebar invite modal.
- Sidebar gains a hidden Admin section that sidebar.ts reveals after a
successful /api/me lookup confirms role='admin' (fails closed on error).
- DE+EN i18n strings for the page, modal and table.
- Tests: require_admin_test.go covers admin-allowed, non-admin 403/302,
unauthenticated 401 and lookup-error fail-closed paths.
Completed deadlines were irreversible — accidental completions could not be
undone. Adds a symmetric reopen path for global admins and project leads.
Server:
- PATCH /api/deadlines/{id}/reopen flips status back to pending and clears
completed_at, audit-logged as project_event kind 'deadline_reopened'.
- DeadlineService.Reopen mirrors Complete shape; new
assertCanAdminProject helper gates on global users.role='admin' OR
paliad.project_teams.role IN ('admin','lead') walking the project path.
- Service test (skipped without TEST_DATABASE_URL) covers admin + non-admin
paths and idempotent no-op.
UI:
- /deadlines/{id} detail: Wieder öffnen / Reopen button replaces the
disabled completed-state Erledigt button (admin/partner only).
- /deadlines list: per-row ↻ icon for completed rows (admin/partner only;
project-lead-only users use the detail page).
- i18n: fristen.detail.reopen, fristen.action.reopen,
dashboard.action.deadline_reopened (DE + EN).
Cache-Control: no-cache on /assets/* (step 3) only applies to NEW
responses — cached entries from before the deploy are still served
without revalidation under heuristic freshness, which is exactly the
window that kept users stuck on the broken bundle.
The robust fix is to change the cache key on every deploy:
- frontend/build.ts now post-processes every dist/*.html and appends
`?v=<buildVersion>` to every /assets/*.js and /assets/*.css URL.
Same buildVersion the SW already uses, so the SW cache, the asset
URL, and the HTML reference all rotate together.
- internal/handlers/handlers.go wraps the protected mux (and the
public /login, /logout, /{$} pages) in a noCachePages middleware.
HTML pages now revalidate on every navigation; combined with the
versioned asset URLs, a deploy reaches users on their next request:
new HTML → new ?v= → fresh script load, every time.
After step 2 deployed the IIFE-wrapped bundles, m's browser still saw
the broken page because /assets/projects.js was being served from the
local HTTP cache (no Cache-Control, just heuristic freshness from
Last-Modified). Even after the new SW activated and cleared its own
caches, its cacheFirst handler did `fetch(req)` which goes through the
browser HTTP cache — re-fed the SW cache from the stale bundle and the
loop perpetuated forever.
Three mutually reinforcing fixes:
1. SW cacheFirst now does `fetch(req, { cache: "reload" })` for the
network leg. Forces the network fetch to bypass the browser's HTTP
cache, so the SW always seeds its own cache from a true network read.
2. Go static handlers for /assets/* and /icons/* set
`Cache-Control: no-cache, must-revalidate`. Combined with the
Last-Modified that http.FileServer already emits, browsers send
If-Modified-Since and the server replies 304 when unchanged — fast
for repeat loads, fresh on every deploy. Users without a SW (or after
the kill-switch unregistered theirs) now also pick up new bundles
immediately.
3. pwa-install.ts gates the install banner on
`(min-width: 768px)` — same breakpoint the BottomNav and other
mobile-shell elements use. Desktop partners no longer get an install
prompt covering their work area.
Ship the installability bits that t-paliad-041 deferred so iOS / Android
users can add Paliad to their home screen.
What landed:
- frontend/public/manifest.json — name=Paliad, theme_color #65a30d (lime),
display=standalone, scope=/, start_url=/dashboard, four icon entries
(192/512 × any/maskable). Served from /manifest.json with the
spec-mandated application/manifest+json content type (servePWAManifest
in internal/handlers/pwa.go).
- frontend/public/icons/ — lime "p" logo rendered to 192/512 PNGs in both
"any" and maskable variants (maskable variant has extra safe-zone
padding), 180×180 apple-touch-icon, 32×32 favicon. SVG sources kept
under frontend/icons-src/ for regeneration via rsvg-convert.
- frontend/public/sw.js — minimal cache-first for /assets/* and /icons/*,
network-first for /api/*, network passthrough for everything else.
CACHE_VERSION + activate-clean lets us bump and purge cleanly. Served
from /sw.js so its scope can claim /; Service-Worker-Allowed: / header
set, no-cache on the SW file itself so updates take effect on next load.
- frontend/src/components/PWAHead.tsx — head fragment (manifest link,
apple-touch-icon, favicon, app-name metas, <script src="/assets/app.js"
defer>). Added to all 30 page TSX files via mechanical insertion.
- frontend/src/client/app.ts — universal client bundle loaded on every
page. Three jobs: register the service worker, init the BottomNav
(icarus flagged that bottom-nav.ts was written but never wired into
the build — m reproduced the broken [+] Anlegen and Menü buttons in
prod), and surface the install banner.
- frontend/src/client/pwa-install.ts — install banner UI. Two flows:
beforeinstallprompt for Chromium/Android (deferred → CTA → prompt),
one-time iOS Safari hint pointing at the share sheet. Both dismissals
persist in localStorage (paliad-install-dismissed / -ios-shown).
- frontend/src/styles/global.css — banner styles, sits above BottomNav on
mobile and pinned bottom-right on desktop, lime-on-white card with the
brand "p" mark.
- frontend/build.ts — copies frontend/public → dist verbatim so the
manifest, icons, and SW land at the application root.
Verification before merge:
- bun run build clean, go build/vet/test clean.
- Local server smoke: curl -sI confirmed manifest.json (200,
application/manifest+json), all icon files (200, image/png), sw.js
(200, Service-Worker-Allowed: /), app.js (200, text/javascript).
- Playwright at 390×844: Chrome fired beforeinstallprompt, the banner
rendered with "Paliad installieren" + "Installieren" CTA in German,
dismiss persisted across reload via localStorage. Manifest validated
in-browser (name/short_name/start_url/display/scope all correct, all
four icon URLs returned 200).
- The InvalidStateError on serviceWorker.register() seen in the MCP
Playwright profile is a known headless flag; SW registration works in
real Chrome / Safari on localhost and HTTPS production.
Out of scope: push notifications, runtime offline mode (SW intentionally
stays minimal — cache shell + assets, network passthrough for everything
else).
Bug 7 — /whatsnew was bare 404. Sidebar uses /changelog (canonical) but
users typing /whatsnew from memory hit the not-found chrome. Added
/whatsnew → /changelog as a 301 to internal/handlers/redirects.go,
following the existing legacy-redirect pattern. Wired on the OUTER mux so
unauthenticated bookmarks redirect one-hop instead of round-tripping
through /login. /search left as-is per the brief — sidebar's global-search
overlay is the live UX, /search would only be hit via typo and falls back
to the chromed 404 from t-paliad-037.
Bug 8 — /settings/caldav worked (200 → 301 → /settings?tab=caldav) but
/settings/notifications, /settings/dezernat, /settings/profile all 404'd.
Tabs themselves were fine in-page; only the deep-link form was broken.
Replaced the single CalDAV-only handler with a generic /settings/{tab}
redirector backed by a slug→canonical map that accepts both the German
tab IDs the client TS understands (profil, benachrichtigungen, dezernat)
and intuitive English aliases (profile, notifications, department).
Unknown slugs fall back to /settings (default tab) instead of 404 so
typos don't break.
Bug 10 — login form 401 console replay: skipped per brief permission.
Reproduced in Playwright; the console message is the browser's automatic
"Failed to load resource: the server responded with a status of 401"
emitted by the network stack itself. login.ts has no console.error call.
The only workarounds (server returns 200 with {ok:false}, or 422 instead
of 401) either compromise the security pattern or don't actually suppress
the browser log. Documented in the smoke delta report.
Verified: go build/vet/test clean, bun run build clean.
Four bugs from tests/smoke-auth-2026-04-25.md.
Bug 4 — Dashboard activity log leaked raw i18n keys. Root cause was a mix
of three issues:
- Go services wrote German event_types (frist_created, termin_*,
projekt_*, notiz_created, checkliste_*) — no matching i18n key.
- i18n.ts only had keys for legacy `akte_*` types, none for what was
actually being written.
- The dashboard renderer always rendered `e.title` (a static label like
"Project angelegt") as a trailing detail, duplicating the action verb.
Old `akte_created` rows had English titles ("Akte created") that
bled into German output.
Switched all event_type writes to English (deadline_*, appointment_*,
project_*, note_created, checklist_*, deadlines_imported). Moved dynamic
text out of `title` into `description` for status_changed and
deadlines_imported so the static label/description split is consistent.
Added i18n keys for both new English types AND legacy German types so
historical project_events rows render cleanly. Dashboard now prefers
description over title; falls back to title only for events with no
i18n match (defensive for any unknown legacy kinds).
Bug 5 — /deadlines and /appointments matter-filter dropdowns showed raw
keys `fristen.filter.project.all` / `termine.filter.project.all`. The
client TS referenced English-prefix keys that didn't exist; the existing
keys use `fristen.filter.akte.*` / `termine.filter.akte.*`. Updated the
client refs to match the existing keys (kept i18n key namespace stable
to avoid touching every other reference).
Bug 6 — /api/departments?include=members returned 500. Reproduced via
curl: ListWithMembers (and ListMembers) used `LEFT JOIN paliad.users` on
a member.user_id that FKs auth.users — pre-onboarding members produced
NULL u.email/display_name/office/role, which sqlx can't scan into the
non-pointer string fields. Switched both to INNER JOIN; unonboarded
members are skipped (correct UX — without a profile there's nothing to
render anyway).
Bug 9 — Bare `404 page not found` on unknown auth-gated paths
(/whatsnew, /search, /settings/notifications, etc). Added a chromed 404
page (frontend/src/notfound.tsx) with sidebar + friendly card + "back
to dashboard" CTA, plus a catch-all handler on the protected mux that
serves it with HTTP 404 (and JSON 404 for /api/* misses). Anonymous
visitors keep being redirected to /login by the auth middleware before
the catch-all runs, so no separate marketing-shell variant needed.
Verification:
- go build ./... + go vet ./... + go test ./... clean
- bun run build clean (notfound.html + notfound.js produced)
- Visual checks pending after deploy
Adds /team page that lists every onboarded Paliad user, grouped by office
(default) or by department, with a free-text search and per-office filter
pills. Each card shows display name, role, primary office (with any
additional offices), department tag, and a mailto: link.
Backend:
- /api/users now also returns additional_offices (column was already on the
model + DB; just missing from the SELECT list).
- /api/departments?include=members returns each department enriched with
its lead user snapshot and the full member list — single fetch for the
"by department" grouping.
- New page handler /team behind the onboarding gate.
Frontend:
- frontend/src/team.tsx + frontend/src/client/team.ts (new) for the page
shell and client-side rendering / filtering.
- New "Team" entry in the Übersicht sidebar group with a users icon.
- DE/EN i18n keys (nav.team, team.*).
- Team-specific CSS for cards, group headers, avatars, and badges.
- Backend: GET /api/projects/tree returns the full visible project tree as
nested JSON with embedded children, open/overdue deadline counts per
node — visibility-scoped via the existing predicate, single round-trip.
- Frontend: new project-tree.ts module renders a collapsible, indented tree
with type icons (client/litigation/patent/case/project), status badges,
deadline-count chips and chevron toggles. Top two levels expand by
default; deeper nodes start collapsed. Expansion state persists in
sessionStorage so toggling list/tree keeps user choices.
- Wired to /projects via the existing Ansicht select (Liste/Baum/Wurzeln);
dedicated tree container coexists with the flat-list table.
- New i18n keys (de/en) + tree styles in global.css (lime accent on hover).
t-paliad-030. Adds `/agenda` — a single page that merges every visible
deadline and appointment into a day-grouped timeline, the third overview
surface alongside Dashboard and the per-resource lists.
- AgendaService: merges paliad.deadlines + paliad.appointments, gated by
the same team-membership predicate used everywhere else; personal
appointments stay creator-only. Items are sorted by date and tagged
with urgency (overdue / today / tomorrow / this_week / later) so the
client can apply the traffic-light colours without re-deriving buckets.
- GET /api/agenda?from&to&types and GET /agenda with the same server-side
hydration pattern as /dashboard (JSON payload spliced into the shell so
the timeline paints on first frame).
- Frontend: agenda.tsx + client/agenda.ts render a day-grouped timeline
with type/range chips; filters round-trip through the URL.
- Sidebar entry under "Übersicht"; DE+EN i18n across all new keys.
Adds a sidebar-wide search bar (t-paliad-026) that hits a single GET
/api/search?q=... endpoint returning grouped results. Static content
(glossary, courts, link hub, checklist templates) is scanned in memory
against the curated Go slices; DB content (projects, deadlines,
appointments, checklist instances, users) is visibility-gated through
the same predicates the normal list endpoints use.
Frontend: new sidebar.ts-owned controller debounces 200ms, renders a
grouped dropdown, supports "/" to focus, Escape/arrows/Enter for
navigation, mobile-full-width overlay, and highlights matches.
Adds a hardcoded changelog (internal/changelog) served via
GET /api/changelog and /api/changelog/unseen-count?since=<iso>, a
/changelog page that renders entries newest-first, and a sidebar
"Neuigkeiten" link with a lime badge showing the count of unseen
entries since the caller's last visit (localStorage stamp).
- internal/changelog: Entry struct, 11 pre-populated entries covering
everything shipped so far (Dashboard, Projects/Deadlines/Appointments,
CalDAV, Checklists v2, Glossary, Courts, Invitations, Settings,
Paliad rename, and the changelog itself).
- Handler: public via auth-gated protected mux. Lexicographic string
compare treats YYYY-MM-DD entries and ISO 8601 cutoffs symmetrically.
- Sidebar: new sidebar-changelog link before the Einladen button; the
badge is populated by a fetch on every page load, suppressed on
/changelog itself to avoid flash, and cleared on visit by stamping
localStorage in changelog.ts's DOMContentLoaded handler.
- i18n: DE + EN keys for nav, page chrome, and tag labels.
- Unit tests for sort order, copy semantics, and same-day cutoff.
Task: t-paliad-027
Unauthenticated bookmark hits on old German URLs (/akten, /fristen, …)
should 301 to the new English path directly. Previously the redirects
lived under the auth middleware, so bookmarked URLs triggered a 302 →
/login → (after login) → 301 round-trip. Registering them on the outer
mux gives the expected one-hop 301.