Commit Graph

46 Commits

Author SHA1 Message Date
m
0c8a2f1a95 feat(t-paliad-151) RemotePaliadinService + main.go env-var routing
Phase B step 2: lands the Paliadin backend that talks to mRiver via
ssh + paliadin-shim. Local backend untouched — selection happens in
cmd/server/main.go based on PALIADIN_REMOTE_HOST.

Files:
- internal/services/paliadin_remote.go (new) — RemotePaliadinService
  + RemotePaliadinConfig, with five SSH knobs (Host/Port/User/KeyPath/
  KnownHostsPath). RunTurn does insertTurnRow → healthGate → bootstrap
  → callShim run-turn → splitTrailer → completeTurn, mirroring the
  local path's audit-row contract. ResetSession sends shim 'reset'.
  callShim runs `ssh -F /dev/null -i <key> -p <port> -o … host -- verb
  args`; ControlMaster intentionally not enabled (design §6.8).
- internal/services/paliadin_remote.go also adds DisabledPaliadinService
  (returns ErrPaliadinDisabled from RunTurn/ResetSession; DB methods
  inherited from paliadinDB still work) so cmd/server/main.go can wire
  a non-nil Paliadin even when neither local tmux nor remote SSH is
  available.
- ErrMRiverUnreachable sentinel for the friendly error code.
- classifySSHError translates ssh exit 124 / Permission denied /
  network errors into the audit-row error_code field.
- Compile-time conformance: var _ Paliadin = (*Local|*Remote|*Disabled)
  PaliadinService(nil).

cmd/server/main.go switch:
  PALIADIN_REMOTE_HOST set → NewRemotePaliadinService
  else: tmux on PATH → NewLocalPaliadinService
  else: NewDisabledPaliadinService

buildPaliadinRemoteConfig materialises PALIADIN_SSH_PRIVATE_KEY +
PALIADIN_KNOWN_HOSTS (multi-line Dokploy secrets) into chmod-600/644
tmpfiles at boot. Defaults: SSHUser=m, SSHPort=22022 (bypasses
Tailscale SSH on :22, see design §4.5). Fails fast on a configured
remote-host without the matching key/known_hosts secrets.

Local-tmux mode now requires `tmux` actually be on PATH at boot
(exec.LookPath gate); previously the constructor unconditionally
returned a service whose RunTurn would fail at runtime with
ErrTmuxUnavailable. The handler-level "friendly error" UX is
unchanged: DisabledPaliadinService surfaces ErrPaliadinDisabled which
the frontend renders the same way.

Build green; existing paliadin_test.go still passes (it tests
package-level helpers, untouched). Remote-specific tests land in B4.

Refs m/paliad#12
2026-05-08 02:16:50 +02:00
m
56a3dc961e refactor(t-paliad-151): extract Paliadin interface; rename PaliadinService → LocalPaliadinService
Phase B step 1 of the Tailscale-SSH route to mRiver. Splits the existing
local-tmux PoC into a Paliadin interface with two implementations; the
remote-SSH backend lands in a follow-up commit (paliadin_remote.go).

Surface:
- Paliadin interface — RunTurn, ResetSession, ListRecentTurns, Stats,
  IsOwner. The handler at internal/handlers/paliadin.go now talks to
  this instead of the concrete struct.
- paliadinDB — embedded base type carrying the audit-table I/O
  (insertTurnRow, completeTurn, markTurnError, markTurnAbandonedOrError)
  plus the read-side queries (IsOwner, ListRecentTurns, Stats). Both
  Local and Remote impls inherit these by embedding paliadinDB so the
  remote path doesn't have to duplicate any DB code.
- LocalPaliadinService — the renamed PoC backend. Identical behaviour
  to the previous PaliadinService; only the type name and method
  receivers change. Method receivers split: tmux-specific operations
  (RunTurn, ResetSession, ensurePane, sendToPane, pollForResponse, etc.)
  stay on *LocalPaliadinService; DB-only operations promote to
  *paliadinDB.

Wiring:
- internal/handlers/handlers.go — Paliadin field becomes the interface
  type; Register() unchanged.
- cmd/server/main.go — calls NewLocalPaliadinService instead of
  NewPaliadinService. The remote-vs-local switch on PALIADIN_REMOTE_HOST
  lands in B5.

Tests in paliadin_test.go all green — they test package-level functions
(splitTrailer, countChips, approxTokenCount, sanitiseForTmux,
PaliadinOwnerEmail) and don't touch the renamed struct. No behaviour
change on the local-tmux path.

Refs m/paliad#12
2026-05-08 02:14:12 +02:00
m
4e1d311a9c feat(t-paliad-149) PR2 step 1/3: backend — migration 061 + CardLayoutService + CardsPreview
Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
  one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
  labels.
- RLS owner-only (mirrors paliad.user_views from t-144).

LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
  ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}

DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).

CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.

ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.

Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).

Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.

Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
  title-row-first invariant, hidden leading allowed, dup-key rejection,
  unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
  enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
  + idempotent, first Create auto-becomes default, SetDefault clears
  prior, Delete refuses active default, Delete non-default works,
  duplicate name rejected, Update round-trips layout JSON.

go build / vet / test (short) clean.

Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
2026-05-07 22:41:18 +02:00
m
8412328dec feat(t-paliad-149) PR1 step 1/3: backend — migration 060 + PinService + BuildTreeWithOptions
Migration 060 (paliad.user_pinned_projects): per-user, RLS owner-only, ON
DELETE CASCADE on both FKs.

PinService (Pin / Unpin / IsPinned / PinnedSet / ListPinned): visibility-
gates pin (can't pin what you can't see) but not unpin (so users can clean
up after losing access). PinnedSet returns a map for O(1) lookups during
tree stitching.

ProjectService.BuildTreeWithOptions extends BuildTree with chip-driven
filtering. New ProjectTreeNode fields are additive (Pinned,
InheritedVisibility, OpenDeadlinesSubtree, OverdueDeadlinesSubtree,
MatchKind) so the old BuildTree(ctx, userID) call still works for legacy
callers. New options:

  Scope: All / Mine / Pinned (Mine + Pinned both expand to path-closure
  with InheritedVisibility flag on greyed ancestors)
  StatusIn / TypeIn: chip-narrowing whitelists
  HasOpenDeadlines: per-node or subtree-aggregated, depending on
  IncludeSubtreeCounts
  SearchTerm: case-fold contains on title/reference/clientmatter, then
  prune to {matches ∪ ancestors ∪ descendants} with match_kind tagged
  IncludeSubtreeCounts: post-order DFS sums, O(N)

GET /api/projects/tree gains query params: scope, status, type,
has_open_deadlines, q, subtree_counts. Zero query string preserves
legacy behaviour.

POST/DELETE /api/projects/{id}/pin and GET /api/user-pinned-projects
wired. Service registered in cmd/server/main.go and dbServices.

build + vet clean.

Design: docs/design-projects-page-2026-05-07.md §4.7, §8.1, §8.3.
2026-05-07 22:21:45 +02:00
m
8d714dd95e fix(t-paliad-146): gate Paliadin to owner email in code, drop PALIADIN_ENABLED
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.
2026-05-07 21:57:20 +02:00
m
7b66c4d035 feat(t-paliad-146): Paliadin PoC — tmux-Claude in-app AI buddy
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.
2026-05-07 21:49:33 +02:00
m
52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00
m
b516201110 feat(t-paliad-144 A1): backend substrate + Custom Views API
Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only;
no user-visible change in A1. A2 (frontend) lands separately.

What's new:

- Migration 056: paliad.user_views table with RLS scoped to caller
  (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE
  (user_id, slug). No is_system flag — system defaults stay code-
  resident per Q8 lock-in.

- internal/services/filter_spec.go (+test): structured FilterSpec
  with Sources / Scope / Time / Predicates. Server-side validator
  rejects unknown sources, duplicate sources, conflicting scope
  modes, horizon=all without explicit projects (Q26 clamp), and
  every per-source enum (deadline.status, appointment_types,
  project_event kinds, approval_request status / viewer_role).

- internal/services/render_spec.go (+test): RenderSpec with three
  shapes (list / cards / calendar — Q4 lock-in 2026-05-07).
  Per-shape config kept separately so flipping shapes preserves
  tweaks. Validator over column / sort / density / group_by /
  default_view enums.

- internal/services/system_views.go (+test): code-resident
  SystemView definitions for dashboard / agenda / events / inbox /
  inbox-mine. Reserved-slug list (Q23) prevents user-views from
  colliding with top-level URLs. Case-folded matching.

- internal/services/view_service.go: extends EventService with
  RunSpec — runs a FilterSpec across all four substrate sources
  (deadline + appointment + project_event + approval_request)
  and merges into []ViewRow sorted by event_date. ViewRow is a
  discriminated projection (kind + common header + per-source
  Detail json.RawMessage). Q17 fail-open attribution: returns
  inaccessible_project_ids for explicit-scope queries where the
  caller can't see some IDs.

- internal/services/user_view_service.go (+test): CRUD on
  paliad.user_views — Create (server-assigns sort_order MAX+1
  in tx), GetBySlug, GetByID, Update (partial), Delete, Touch
  (last_used_at), MostRecent. Reserved-slug + slug-format
  validators on every write.

- internal/handlers/views.go: nine HTTP handlers wiring the
  endpoints (GET/POST/PATCH/DELETE /api/user-views/...,
  POST /api/user-views/{id}/touch, POST /api/views/run,
  POST /api/views/{slug}/run, GET /api/views/system).

- main.go + handlers.go + projects.go: wire UserViewService
  into the bundle; conditional route registration when both
  UserView + Event services are present.

Pure-Go tests (no DB): 32 cases pass — filter spec validators,
render spec validators, system view registry, reserved slugs.

Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases
covering create / list / get / uniqueness / update / delete /
touch / most-recent / reserved-slug / bad-slug / empty-name /
invalid-spec.

Coexists with t-139 (in-flight on noether's other branch) and
t-138 (shipped) without coordination commits — RunSpec uses the
existing visibility predicate that t-139's migration 055 will
extend with derivation. Approval-request source delegates to
ApprovalService.ListPendingForApprover / ListSubmittedByUser
(both already extended for derived_peer authority in t-139 Phase 3).

Files: 15 changed, 3134 insertions. Build clean. Tests green.
2026-05-07 12:51:37 +02:00
m
544bb63684 feat(t-paliad-139): Phase 2 — partner-unit derivation schema + Team-tab subsections
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.
2026-05-06 16:41:41 +02:00
m
10b3426086 feat(t-paliad-138): wire ApprovalService into deadline + appointment paths
Commit 3 of 8. The 4-eye gate now actually fires. With migration 054
applied and an approval_policies row configured for a project, the
relevant Create/Update/Complete/Delete on a Deadline or Appointment
flips approval_status='pending' and emits a *_approval_requested audit
event. Without policies, behaviour is unchanged.

Backend changes:

- models.Deadline + models.Appointment gain approval_status,
  pending_request_id, approved_by, approved_at; appointments also gain
  completed_at (for the appointment:complete lifecycle event).
- deadlineColumns + appointmentColumns include the new fields so
  every existing read path hydrates them via sqlx StructScan with
  no per-call-site changes.
- DeadlineService gains SetApprovalService (nil-tolerant). Wired in
  main.go after the bundle is built.
- AppointmentService gains the same hook + dependency.

Lifecycle wiring:

- DeadlineService.Create / Update / Complete / Delete each consult
  the approval gate. Update only triggers approval when a date-bearing
  field actually changes (Q4 allowlist: due_date, original_due_date,
  warning_date). Cosmetic edits (title, description, notes,
  rule_code, event_type_ids, status, completed_at via reopen) bypass.
- AppointmentService.Create / Update / Delete same shape. Update
  only gates on start_at / end_at changes. Personal appointments
  (project_id IS NULL) never gate (no project policy to consult).
- Delete is the one stage-then-write exception: the row stays alive
  with approval_status='pending' until the approver hard-deletes
  (approve) or restores it (reject). On no-policy projects, delete
  is immediate as before.
- Concurrent-pending guard: any mutation on a row whose
  approval_status='pending' returns ErrConcurrentPending. The user
  must wait for the in-flight request to settle (or revoke if
  they're the requester).

Pre_image capture: the date-bearing fields that are about to change
are snapshotted into the approval_requests.pre_image jsonb at submit
time. Reject/Revoke applies them back over the row to revert.
2026-05-06 15:27:40 +02:00
m
4ebbf2c1af feat(t-paliad-138): ApprovalService core + tests
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.
2026-05-06 15:21:47 +02:00
m
d72990ad1b feat(t-paliad-122): country+regime aware HolidayService + CourtService
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.
2026-05-06 12:47:12 +02:00
m
f40b652d01 Reapply "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"
This reverts commit 5bd17de732.
2026-05-05 11:18:38 +02:00
m
5bd17de732 Revert "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"
This reverts commit f7d72ff1d3, reversing
changes made to 1ea983f0c7.
2026-05-05 11:17:58 +02:00
m
7141f4a954 feat(t-paliad-133): Phase C — B1 decision tree cascade + search extension
Wires the v3 Pathway B / B1 decision-tree cascade end-to-end. The
existing Phase D search backend gains two new query params, and the
frontend gets a data-driven button-cascade UI that walks
paliad.event_categories step-by-step.

Backend extension:
- internal/services/deadline_search_service.go
  - SearchOptions gains EventCategorySlug + Forums fields.
  - DeadlineSearchService gains an EventCategoryService dependency
    via SetEventCategoryService(); wired in main.go after both
    services exist (cross-link order).
  - ForumToProceedingCodes map (10 buckets per m's spec lock §10 Q8)
    translates v3 forum slugs to proceeding_type codes. Lives in Go
    so rebucketing = code change, not migration.
  - browseRanks() new query path: when q is empty AND
    EventCategorySlug is set, synthesise rank rows from the slug's
    reachable concept_ids — no trigram, just sort by
    concept_sort_order. Drives B1 narrowing.
  - rankConcepts() + loadPills() gain optional concept_id allow-list
    + forum_codes filters via UNIQUE NULLS NOT DISTINCT-shaped IS-NULL-OR
    PARAM clauses. Trigger pills (kind='trigger') always pass forum
    filter — they're cross-cutting by design.

- internal/handlers/fristenrechner_search.go
  - Reads new ?event_category_slug= and ?forum= (comma-separated)
    query params. Forwards to SearchOptions.
  - parseCSV() helper.

Frontend B1 cascade:
- frontend/src/client/fristenrechner.ts
  - loadEventCategoryTree(): one-shot fetch + in-memory cache of
    /api/tools/fristenrechner/event-categories.
  - renderB1Cascade(slug): renders breadcrumb + step question +
    button row + skip-step + step-back. Buttons walk down, breadcrumb
    walks back. Empty path = root question + 6 root buttons.
  - runB1Search(slug): hits /api/tools/fristenrechner/search?event_category_slug=
    and reuses Phase D's renderSearchResults() for the card list.
    Empty-result path shows "Schritt zurück" link (m's spec lock §10 Q6
    rephrase from "Pfad lockern").
  - URL state ?b1=<dot-path> round-trips. popstate restores cascade.
  - Pathway B default mode flips from filter → tree (B1 is now the
    discovery surface; B2 is for power users).

Frontend i18n: +1 key (deadlines.pathway.b.tree.start_question).

Frontend CSS: .fristen-b1-breadcrumb, .fristen-b1-crumb,
.fristen-b1-question, .fristen-b1-buttons, .fristen-b1-button (with
--leaf modifier border-left accent), .fristen-b1-skip,
.fristen-b1-step-back rules.

Frontend build clean (1473 keys). go build + vet + tests clean.
2026-05-05 11:03:34 +02:00
m
2c770ef02f feat(t-paliad-133): Phase A — EventCategoryService + handler + route
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.
2026-05-05 10:51:58 +02:00
m
b45278b060 feat(t-paliad-131): Phase C — search backend (matview + service + handler)
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).
2026-05-05 04:32:50 +02:00
m
2102dfd07d feat(t-paliad-110): add EventService + /api/events + /api/events/summary
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.
2026-05-04 13:37:20 +02:00
m
460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
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.
2026-04-30 16:46:31 +02:00
m
04ce6a8bfa feat(t-paliad-088): Event Types for deadlines — schema + service + handlers (PR-1)
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.
2026-04-30 12:49:04 +02:00
m
d78f20be8a feat(t-paliad-086): trigger-event Fristenrechner mode + working_days primitive — PR-2
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).
2026-04-30 11:04:32 +02:00
m
e468930342 fix(t-paliad-077): /api/links/suggest 500 — switch to sqlx for paliad.link_*
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).
2026-04-30 03:18:03 +02:00
m
832104af9e Merge remote-tracking branch 'origin/main' into mai/cronus/partner-units-rename
# Conflicts:
#	frontend/build.ts
#	frontend/src/admin.tsx
#	frontend/src/client/i18n.ts
#	internal/handlers/handlers.go
2026-04-29 22:17:32 +02:00
m
0e3411c40b feat(admin): /admin/email-templates editor (t-paliad-072)
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.
2026-04-29 22:09:39 +02:00
m
76785da3f6 feat(t-paliad-070): rename Department → PartnerUnit on the Go side
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.
2026-04-29 22:03:08 +02:00
m
2422603abf feat(admin): /admin/audit-log global timeline (t-paliad-071)
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.
2026-04-29 19:12:11 +02:00
m
495e519475 feat(t-paliad-065): firm-agnostic branding via single FIRM_NAME constant
Paliad ships firm-agnostic per CLAUDE.md ("survives firm renames") but
landing copy, email templates, page titles, and form placeholders still
hard-coded "Hogan Lovells" / "HL Patents". Replaces every user-facing
firm reference with a single source of truth: internal/branding.Name on
the server and frontend/src/branding.ts in the bundle, both reading
FIRM_NAME at startup/build time and defaulting to "HLC".

Server: branding package + boot log; auth, invite, admin_users error
strings; courts/offices/models comments; mail templates thread
{{.Firm}} via injected payload default. Files handler keeps the
upstream "HL Patents Style.dotm" path (must match mWorkRepo's blob
name) but renders the user-visible DownloadName from branding.Name.

Frontend: branding.ts read via Bun.build define so process.env.FIRM_NAME
is statically substituted into client bundles (no runtime process
reference); index/login/downloads/kostenrechner/Sidebar/ProjectFormFields
and every i18n.ts string templated against ${FIRM}.

ALLOWED_EMAIL_DOMAINS whitelist intentionally untouched — email
domains and display name rotate independently.

Verified: go build/vet/test clean; bun run build clean; FIRM_NAME=Acme
override produces "Acme" in HTML and JS bundles end-to-end.
2026-04-28 22:44:06 +02:00
m
f988666ba0 fix(t-paliad-064): embed tzdata + stop silent UTC fallback (PR-1)
Root cause of m's 11:16 reminder emails: the alpine runtime image installs
only ca-certificates and ships no /usr/share/zoneinfo, so
time.LoadLocation("Europe/Berlin") errored in production. inSlot's silent
UTC fallback then matched reminder_morning_time=09:00 against now.UTC().Hour(),
firing at 09:00 UTC = 11:00 Berlin (CEST UTC+2) plus the ticker phase.

Fix:
- import _ "time/tzdata" in cmd/server/main.go (and the services package
  for test parity) — embeds Go's IANA database in the binary, ~450KB,
  works without OS tzdata.
- inSlot now logs and returns false on bad tz instead of pretending the
  user lives in UTC. matchesLocalDueDate mirrors the same change.
- Tests updated: previous "Mars/Olympus falls back to UTC" expectation
  flipped to "skips user", new TestTZDataEmbedded asserts
  LoadLocation("Europe/Berlin") works in the test binary, new
  TestInSlot_BerlinAt0900_NotAt1100 locks the headline regression
  (must fire at 07:05 UTC = 09:05 Berlin, must NOT fire at 09:16 UTC =
  11:16 Berlin).

Validation at the user-save boundary (UserService line 296-300, 619)
already rejected unparseable IANA names — that remains; this PR only
hardens the read path so any pre-existing corrupt rows skip rather than
silently reroute.

Build/vet/test/bun-build all green. Self-merging to main.
2026-04-28 13:02:58 +02:00
m
0d6c58a337 feat(agenda): unified timeline of deadlines + appointments across projects
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.
2026-04-22 23:38:03 +02:00
m
49c6bc75ca refactor(rename): handler functions, routes, legacy 301 redirects
Second rename pass closing the backend cleanup:

* handler functions (handleListProjekte, handleCreateFrist, …) renamed
  to English equivalents so every symbol in the handler package matches
  the URL/entity it serves.
* services.FristStatusFilter + filter constants renamed to
  DeadlineStatusFilter / DeadlineFilterOverdue etc.
* services.TerminListFilter / TerminCalDAVPusher / TerminSummaryCounts
  renamed to AppointmentListFilter / AppointmentCalDAVPusher /
  AppointmentSummaryCounts.
* GlossarTerm/GlossarSuggestion/glossarTerms → Glossary*.
* CourtsFeedback/CourtsResponse (formerly Gerichte*).
* handlers.Services.{Projekt,Parteien,Frist,Termin,Notiz,Dezernat} →
  {Project,Party,Deadline,Appointment,Note,Department}; dbServices
  struct + consumers likewise.
* email templates: {{.FristURL}} → {{.DeadlineURL}}, {{.FristenURL}} →
  {{.DeadlinesURL}}.
* links.go category IDs: gerichte → courts.
* cmd/server/main.go local vars: projektSvc/terminSvc/dezernatSvc →
  projectSvc/appointmentSvc/departmentSvc.

Routes:
* removed all /api/akten alias routes (API clients use /api/projects now).
* removed /api/akten/*/deadlines, /*/notes, /*/parties, /*/appointments,
  /*/checklists, /*/events, /*/summary alias variants.
* new internal/handlers/redirects.go registers 301 Moved Permanently
  redirects for every legacy German GET path: /akten, /projekte, /fristen,
  /termine, /notizen, /einstellungen, /checklisten, /dezernate, /parteien,
  /gerichte, /glossar. Sub-paths + query strings are preserved so old
  bookmarks keep working.

Kept in German (product names, per task spec):
* /tools/fristenrechner, /tools/kostenrechner, /tools/gebuehrentabellen
* FristenrechnerService / KostenrechnerService types
* User.Dezernat + paliad.users.dezernat free-text legacy column (separate
  from the new paliad.departments entity).

go build / vet / test clean.
2026-04-20 17:40:55 +02:00
m
3faec6c526 refactor(rename): German→English for backend (tables, types, services, handler files)
t-paliad-025 — Phase 1: backend rename.

Migrations 018+019 rewritten from scratch with English table/column
names throughout. Since v2 schema (018/019) has never been applied to
youpc prod DB, this is a clean replacement — not an ALTER RENAME chain.
Pre-existing German tables (parteien, fristen, termine, dokumente,
akten_events, notizen) are renamed inline in 018 via ALTER TABLE … RENAME
TO alongside the akte_id → project_id column rewrite.

Renames applied:
  projekte            → projects
  projekt_teams       → project_teams
  projekt_events      → project_events (via akten_events → project_events)
  fristen             → deadlines
  termine             → appointments
  parteien            → parties
  notizen             → notes
  dezernate           → departments
  dezernat_mitglieder → department_members
  dokumente           → documents
  can_see_projekt     → can_see_project
  notiz_is_visible    → note_is_visible
  akte_id  / frist_id / termin_id / akten_event_id → project_id /
    deadline_id / appointment_id / project_event_id
  termin_type → appointment_type

Go types + services renamed:
  Projekt / ProjektService / ProjektEvent / ProjektTeamMember
  Frist / FristService / FristWithProjekt
  Termin / TerminService / TerminWithProjekt / TerminType
  Notiz / NotizService / ChecklistInstanceWithProjekt
  Dezernat / DezernatService / DezernatMitglied
  Partei / Parteien / ParteienService

Files renamed (git mv):
  internal/services/{projekt,frist,termin,notiz,dezernat,parteien}_service.go
    → {project,deadline,appointment,note,department,party}_service.go
  internal/handlers/{projekte,fristen,fristen_pages,termine,termine_pages,
    notizen,dezernate,akten_pages,gerichte,glossar,checklisten}.go
    → {projects,deadlines,deadlines_pages,appointments,appointments_pages,
       notes,departments,projects_pages,courts,glossary,checklists}.go
  internal/checklisten/ → internal/checklists/
  internal/db/migrations/018_projekte_v2.* → 018_projects_v2.*
  internal/db/migrations/019_seed_dezernate_from_user_text.*
    → 019_seed_departments_from_user_text.*

User-facing i18n strings (DE/EN labels) stay untouched. Product names
Fristenrechner / Kostenrechner / Gebührentabellen stay German.

Build + vet + tests clean.
2026-04-20 17:35:38 +02:00
m
cb2841fba9 feat: handlers — Projekt/Team/Dezernat wiring (Phase 2)
- handlers/projekte.go (was akten.go): Projekt CRUD + tree ops (children,
  tree, ancestors), events cursor-paginated, parteien endpoints.
- handlers/teams.go: GET/POST/DELETE on /api/projekte/{id}/team. ListEffectiveMembers
  returns direct + inherited (annotated with inherited_from_id/title).
- handlers/dezernate.go: admin-gated CRUD for paliad.dezernate + member
  add/remove. Readable by any authenticated user.
- handlers/fristen.go, termine.go, notizen.go, checklist_instances.go updated
  to use projekt_id. Kept /api/akten/{id}/fristen|termine|notizen|checklisten
  as legacy aliases pointing at the same projekt-aware handlers.
- handlers/users.go: dropped handleListAkteEvents (superseded by
  handleListProjektEvents under /api/projekte/{id}/events).
- cmd/server/main.go: ProjektService + TeamService + DezernatService wired
  into handlers.Services. Downstream services (Parteien, Frist, Termin,
  Notiz, Checklist) take projektSvc.
- Removed obsolete internal/services/akte_service_test.go. go build/vet/test
  all clean.

Legacy /api/akten routes still resolve (handlers/JSON shape unchanged on
the GET/POST path) so frontend stays functional during the client cutover.
New /api/projekte routes live alongside.

Phase 3 (frontend tree UI, /projekte page, team tab) + Phase 4 (Dezernat
settings tab) still pending.
2026-04-20 14:52:44 +02:00
m
11217f7bfa feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
  html/template rendering, branded base layout + content templates, silent
  no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
  overdue / due tomorrow / due within the week (Monday digest). Dedup via
  paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
  whitelist, in-memory 10/day/user rate limit, audit row in
  paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
  question; current pilot keeps identity mails on Supabase default sender.

Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
2026-04-20 12:34:38 +02:00
m
3e20806aee fix(security): verify JWT signatures + plug 4 other critical gaps (t-paliad-016)
C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
  HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
  reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
  outright and clear cookies.

C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
  TerminService.canSee: personal Termine (akte_id IS NULL) are
  creator-only; admins do NOT see other users' personal Termine.

C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
  partner/admin (or the original creator). Personal Termine stay
  creator-only.

C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
  (default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
  whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.comname@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.

C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
  and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
  ANTHROPIC_API_KEY line for Phase H readiness.

Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
  token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
  whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
2026-04-18 02:23:50 +02:00
m
4c0babb2f3 feat(checklisten): instanceable checklists — DB-backed state, Akte linkage
Checklisten move from one-per-slug localStorage state to a template/instance
model. A user creates multiple named instances of each template (UPC SoC,
EPA Einspruch, …), each with its own checkbox state in paliad.checklist_instances
and an optional akte_id for office-wide visibility.

- Migration 014: paliad.checklist_instances + RLS mirroring the Termine
  pattern (akte_id nullable → creator-only; akte_id set → can_see_akte gate).
- Static template data moves out of internal/handlers into internal/checklisten
  so both handlers and the new ChecklistInstanceService can reference it
  without an import cycle.
- ChecklistInstanceService: CRUD + state merge via `state || $n::jsonb`
  so concurrent checkbox toggles don't clobber each other. Reset clears
  state to {}. Akte-linked mutations append akten_events audit rows.
- Handlers: GET/POST /api/checklisten/{slug}/instances, GET/PATCH/DELETE
  /api/checklisten/instances/{id}, POST .../reset, GET /api/akten/{id}/checklisten.
- /checklisten/{slug} redesigned to show template metadata + instance
  table + "Neue Instanz" modal (with optional Akte dropdown). The
  interactive checkboxes move to /checklisten/instances/{id} where the
  state is DB-backed and Reset posts to the server. Fixes the original
  Reset button regression — it now operates on real server state rather
  than silently failing client-side.
- Akten detail grows a Checklisten tab listing linked instances with
  progress bars; only loads on tab activation.
- localStorage-based progress removed from the overview grid (state no
  longer lives there).
- DE + EN i18n keys added.

Verified: bun run build clean; go build ./...; go vet ./...; go test ./...
all green.
2026-04-17 13:54:32 +02:00
m
5a9f8e5874 feat(notizen): Phase I — Notizen (polymorphic notes)
Polymorphic notes attached to Akten, Fristen, Termine, or AktenEvents.
Schema (paliad.notizen + paliad.notiz_is_visible) shipped with Phase A
migrations; this phase adds the service, handlers, and shared UI.

Backend
- NotizService (internal/services/notiz_service.go): ListForAkte /
  ListForFrist / ListForTermin / ListForAktenEvent + Create / Update /
  Delete. Visibility resolves through the parent row — AkteService.GetByID
  for Akte/Frist/AktenEvent parents, TerminService.GetByID for Termin
  parents (personal Termine are creator-only).
- Edit restricted to the original author; delete allows author +
  partner/admin. Create on an Akte-scoped parent appends an akten_events
  "notiz_created" audit row in the same transaction; personal Termin
  notes skip the audit.
- Author join (paliad.users) surfaces display_name + email on every
  listed note so the client can render "von <Name>" without per-row
  /api/users fetches.
- Routes wired in handlers.go: GET/POST /api/akten|fristen|termine/{id}/
  notizen, PATCH/DELETE /api/notizen/{id}.

Frontend
- Shared client module frontend/src/client/notizen.ts exposes
  initNotes(container, parentType, parentId). Renders an add-note form,
  list of note cards with relative timestamps (gerade eben / vor N
  Minuten / gestern / …), edit + delete affordances gated by author/
  role, optimistic add/edit/delete with rollback on error, Ctrl+Enter
  submit, and URL auto-linkification inside sanitised note bodies.
- Integrated into akten-detail (Notizen tab — placeholder replaced),
  fristen-detail (new "Notizen" section below the detail list), and
  termine-detail (new "Notizen" section above the edit form).
- DE + EN i18n keys added; obsolete akten.detail.soon.notizen placeholder
  keys removed.
- Notiz-card styles added to global.css (accent-coloured focus, hover
  actions, relative-time colour) matching the existing Verlauf card look.
2026-04-17 12:12:29 +02:00
m
b56ef660df feat(termine): Phase F — Termine (appointments) + CalDAV sync
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.

Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
  Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
  Termine follow AkteService.GetByID. Every Akte-attached mutation appends
  an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
  caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
  UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
  Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
  to start with malformed key; unset key leaves CalDAV disabled and all
  /api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
  paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
  /api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.

Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
  TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
  test-connection button, status card, sync log table, delete button that
  purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
  add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.

Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password

t-paliad-010
2026-04-17 11:59:49 +02:00
m
bebf79ba63 Merge Phase G: Dashboard landing
# Conflicts:
#	frontend/build.ts
#	frontend/src/styles/global.css
2026-04-16 17:30:26 +02:00
m
316dc9f9bf feat(fristen): Phase E — Persistent deadline management UI
Adds the persistent-deadline layer on top of the Phase A schema:

Backend (Go)
- internal/services/frist_service.go: CRUD + bulk import + summary
  counts, all gated through AkteService.GetByID for office-scoped
  visibility. Every mutation writes an akten_events row.
- internal/handlers/fristen.go: GET/POST/PATCH/DELETE for /api/fristen,
  /api/fristen/{id}, /api/fristen/{id}/complete, /api/fristen/summary,
  /api/akten/{id}/fristen, /api/akten/{id}/fristen/bulk.
- internal/handlers/fristen_pages.go: serves the four new HTML pages.
- Models: Frist + FristWithAkte (joined for the list page).
- Service wired into cmd/server/main.go.

Frontend (Bun TSX + per-page client TS)
- /fristen        — list with traffic-light summary cards (red/amber/
                    green), status + Akte filters, inline mark-complete.
- /fristen/neu    — create form (Akte select, due date, optional rule
                    + notes); /akten/{id}/fristen/neu pre-selects.
- /fristen/{id}   — detail with inline edit, complete, role-gated delete.
- /fristen/kalender — month grid with deadline dots + day popup.
- Akten detail "Fristen" tab now shows the real list (Phase D
  placeholder removed).
- Fristenrechner: "Als Frist(en) speichern" CTA opens a modal that
  picks an Akte + which calculated rows to import (POSTs to /bulk).
- Sidebar: activates the Fristen entry (was greyed-out in Phase D).
- DE/EN i18n for all new copy.
- Traffic-light + calendar styles in global.css.

Visibility, audit and role-gating reuse the Phase B/D primitives —
no new RLS or auth surface.
2026-04-16 17:28:44 +02:00
m
b79ef258ef feat(dashboard): Phase G — logged-in landing page
New /dashboard route serves the authenticated home screen with a
server-rendered payload (no skeleton→fetch waterfall, per design
audit §2.3). / now redirects authenticated visitors to /dashboard
and keeps the marketing landing for anonymous visitors.

- DashboardService aggregates deadline + matter summaries, the next
  7d of Fristen/Termine, and the last 10 akten_events, all scoped
  by the standard office-visibility predicate.
- Dashboard handler splices the JSON payload into dist/dashboard.html
  as window.__PALIAD_DASHBOARD__ so the client paints on first frame;
  client re-fetches /api/dashboard every 60s to stay current.
- Sidebar gains an "Übersicht" group with the Dashboard entry at the
  top; DE/EN i18n keys + traffic-light card styles added.
- Empty-state copy, onboarding hint, and 503 handling keep the page
  intact when DATABASE_URL is unset.
2026-04-16 17:27:42 +02:00
m
d1909c766e feat: Phase C — Fristenrechner → DB-backed via FristenrechnerService
- Delete internal/calc/deadlines.go/deadline_rules.go/holidays.go (ported to services)
- fristenrechner handler routes through FristenrechnerService when pool present
- Returns 503 with German message when DATABASE_URL unset (page still renders)
- Migration 012: add name_en columns + seed 9 UI-facing proceeding types
- Commit captures cronus's work after session termination
2026-04-16 17:11:02 +02:00
m
bcc4939af2 feat(services): Phase B — sqlx pool, services, Akten/Frist endpoints
Implements docs/design-kanzlai-integration.md §8 Phase B.

Pool & infrastructure:
- internal/db/pool.go — sqlx connection pool via DATABASE_URL
  (lazy, sync.Once, returns nil if unset)
- cmd/server/main.go wires pool + services on startup; skips gracefully
  if DATABASE_URL unset (existing endpoints still work)

Services (internal/services/):
- holidays.go — ported from KanzlAI. Audit §1.6 fix: replaces unguarded
  map with sync.Map of *yearEntry (sync.Once per year), race-safe under
  concurrent readers.
- deadline_calculator.go — ported. days/weeks/months + before/after
  timing + holiday/weekend adjustment via HolidayService.
- deadline_rule_service.go — ported, DB-backed. List, GetRuleTree,
  GetFullTimeline (recursive CTE for cross-type spawns), GetByIDs,
  ListProceedingTypes.
- user_service.go — reads paliad.users; GetByID returns (nil, nil) for
  users who haven't onboarded yet (safe default = no visibility).
- akte_service.go — new. Office-scoped visibility enforced at the app
  layer (defense-in-depth alongside RLS). ListVisibleForUser uses the
  visibility predicate directly in SQL so indexes can drive the query.
  Create/Update/Delete enforce role gates:
    * associates can only create in their own office
    * only admins can move an Akte between offices
    * only partners/admins can toggle firm_wide_visible
    * only partners/admins can delete (soft, status='archived')
  Writes an akten_events row on create, status change, firm-wide toggle,
  collaborator change.
- parteien_service.go — ported. Visibility inherited from the parent
  Akte via AkteService.GetByID gate.

Sentinel errors:
- services.ErrNotVisible → handlers return 404 (never leak existence)
- services.ErrForbidden → 403
- services.ErrInvalidInput → 400

Auth context:
- internal/auth/user.go — WithUserID middleware extracts the `sub` claim
  from the Supabase JWT session cookie and injects uuid.UUID into the
  request context. Runs after Client.Middleware (which already validated
  the cookie expiry). Handlers use auth.UserIDFromContext().

Handlers (internal/handlers/):
- akten.go — full CRUD for /api/akten + /api/akten/{id}/parteien.
  All require DB configured (503 otherwise) and authenticated user
  (401 otherwise). Returns 404 for non-visible IDs.
- deadline_rules_db.go — GET /api/deadline-rules, GET
  /api/proceeding-types-db, POST /api/deadlines/calculate.
  The /api/deadlines/calculate endpoint lives alongside the existing
  in-memory /api/tools/fristenrechner; Phase C swaps the UI over and
  deletes the in-memory rule tree.
- handlers.Register now takes an optional *Services bundle; when
  DATABASE_URL unset the DB-backed endpoints return 503 with a clear
  error message.

Tests (internal/services/):
- holidays_test.go — Easter algorithm (5 years spot-checked), German
  federal holidays, weekend + Neujahr adjustment, concurrent cache
  reads under -race.
- deadline_calculator_test.go — days/weeks/months calc, before timing,
  Karfreitag→Ostermontag skip (lands on Tue 2026-04-07), batch with
  zero-duration rule.
- akte_service_test.go — live DB test behind `TEST_DATABASE_URL` (skip
  otherwise). Verifies 4-Akte × 3-user visibility model AND role
  enforcement (associate can't delete, can't cross-office-create,
  invalid office rejected).

Manual verification:
- `go build ./...` + `go vet ./...` clean
- `go test ./internal/services/ -race` passes (DB tests skip without URL)
- With TEST_DATABASE_URL set, all visibility + role tests pass
- Live HTTP smoke test with forged JWT cookie:
  * /api/deadline-rules returns 40 rules
  * /api/proceeding-types-db returns 7 types
  * /api/deadlines/calculate INF + 2026-04-15 returns calculated deadlines
  * /api/akten returns [] (user has no paliad.users row yet — safe default)
  * /login, / still work (no regressions)
2026-04-16 14:25:55 +02:00
m
1b2ef28334 feat(db): Phase A — paliad schema, RLS, migrations, golang-migrate
Implements docs/design-kanzlai-integration.md §8 Phase A.

Schema (paliad.*):
- users (extends auth.users) with office, practice_group, role
- akten with visibility columns: owning_office, collaborators uuid[],
  firm_wide_visible (per design §2)
- parteien, fristen, termine, dokumente, akten_events, notizen
  (polymorphic notes; notizen_exactly_one_parent CHECK)
- proceeding_types, deadline_rules, holidays (reference data)
- 4 feedback tables re-namespaced from public.* into paliad.*
  (handler swap to direct DB is a follow-up; old public tables stay
  intact for now and continue serving via PostgREST)

Visibility (paliad.can_see_akte):
- single SQL function, used by every RLS policy
- predicate: firm_wide_visible OR owning_office matches user's office
  OR auth.uid() ∈ collaborators OR user is admin
- mirrored at app layer in Phase B (defense in depth)

RLS (real, not permissive):
- akten: visibility predicate; insert restricted to own office or admin;
  delete restricted to partners + admins
- parteien/fristen/dokumente/akten_events: inherit via can_see_akte(akte_id)
- termine: personal (akte_id NULL) visible only to creator; Akte-linked
  follow visibility predicate
- notizen: paliad.notiz_is_visible() resolves polymorphic parent
- reference tables: SELECT for any authenticated user
- users: SELECT all; UPDATE/INSERT only self
- feedback tables: INSERT for any authenticated user (write-only)

Seed data (ported from KanzlAI seed_upc_timeline.sql):
- 7 proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL)
- 40 deadline_rules (32 UPC + 4 ZPO + 4 cross-type appeal spawns)
  including conditional logic: Reply rule code (RoP.029b → 029a) and
  Rejoinder duration (1mo → 2mo) flip when CCR active
- 55 holidays (DE federal 2026/2027 + UPC summer 2026 + UPC winter 26/27)

Indexes per audit §3.3 + visibility-predicate hot paths:
- akten: (status, owning_office), (owning_office), partial on
  firm_wide_visible, GIN on collaborators
- fristen: (status, due_date), (akte_id)
- termine: (start_at), (akte_id)
- akten_events: (akte_id, created_at DESC)
- notizen: 4 partial indexes per parent type
- users: (office), (role)

Migration tooling:
- golang-migrate/migrate/v4 with embed.FS source
- Migrations live in internal/db/migrations/ (Go embed can't reach
  outside the package; this is the conventional Go layout for embedded
  migrations)
- Applied at server startup before HTTP listener binds
- DATABASE_URL is optional today (existing knowledge tools work without
  DB); becomes required once Phase B services land
- Mock Supabase auth schema for local testing in
  internal/db/migrations/_dev/mock_supabase_auth.sql (excluded from
  embed pattern by the underscore prefix)

Other changes:
- Dockerfile: bump golang to 1.24, copy go.sum (audit §2.9), rename
  binary patholo → paliad
- docker-compose.yml: add DATABASE_URL passthrough
- README.md: rewritten to reflect Paliad brand + Phase A migration system

Verified locally:
- 11 migrations applied cleanly against postgres:16-alpine
- RLS enabled on all 15 paliad.* tables (verified via pg_class.relrowsecurity)
- Visibility predicate verified with 4-case scenario:
  - Alice (Munich associate): sees Munich + firm-wide + collab-on (t f t t)
  - Bob (Düsseldorf associate): sees Düsseldorf + firm-wide + collab-on (f t t t)
  - Carol (Munich partner): sees Munich + firm-wide only (t f t f)
  - Anonymous: sees firm-wide only (f f t f)
- migrate down + re-up cycle clean (initial 007 down had ordering bug,
  fixed: drop policies before referenced function)
- Existing endpoints (/, /login) return 302 + 200 — no regressions
2026-04-16 13:54:19 +02:00
m
77061b2708 feat: file proxy — serve HL Patents Style.dotm from Gitea with cache
Add GET /files/{filename} route (behind auth) that proxies files from
Gitea raw URLs with in-memory caching. Uses SHA-based cache invalidation:
checks Gitea commit API every 5 min, only re-downloads when file changes.

- internal/handlers/files.go: proxy handler with SHA-based cache
- POST /api/files/refresh: cache-bust endpoint
- GITEA_TOKEN env var for private repo access
- Download card on landing page with i18n DE/EN
2026-04-14 18:32:12 +02:00
m
21bf56dc20 feat: add Supabase password auth with @hoganlovells.com restriction
Go server authenticates against Supabase GoTrue (youpc instance) using
email+password. Login page with login/register tabs, domain restricted
to @hoganlovells.com. Auth middleware protects all routes, refreshes
expired tokens via refresh_token cookie. Lime green branding.

- internal/auth: Supabase client (sign in, sign up, refresh, sign out),
  JWT expiry decode, auth middleware, cookie management
- internal/handlers: login/register/logout handlers, per-page template
  parsing to avoid content block collisions
- templates/login.html: tabbed login/register form
- 30-day HTTP-only session cookies with SameSite=Lax
- SUPABASE_URL and SUPABASE_ANON_KEY env vars in docker-compose
2026-04-14 16:34:17 +02:00
m
d2a9164e36 fix: add missing cmd/server/main.go and fix .gitignore
The .gitignore pattern "server" was matching cmd/server/ directory,
preventing main.go from being tracked. Anchored binary patterns to
repo root with leading slash.
2026-04-14 16:04:30 +02:00