Commit Graph

502 Commits

Author SHA1 Message Date
m
215a1ceeda fix(t-paliad-144): Custom Views toast — replace hardcoded light-yellow (#fff8db / #f3d27a / #5b4304) with paliad tokens (lime-tint bg + border-strong + text). Was theme-blind, stayed bright in dark mode. 2026-05-07 21:16:06 +02:00
m
e4adc39833 Merge: t-paliad-147 — bulk team email (migration 057 + BroadcastService + /team filter+compose modal + /admin/broadcasts viewer) 2026-05-07 21:01:12 +02:00
m
3dffce7a0d Merge: fix Custom Views dark-mode contrast (token name mismatch) 2026-05-07 21:00:51 +02:00
m
d8b84d0c58 fix(t-paliad-144): Custom Views CSS — replace bare-name tokens (--surface, --text-muted, --border-subtle, --surface-subtle, --surface-hover) with paliad's actual --color-* tokens. The bare names don't exist in paliad's design system; the hardcoded fallbacks (#fff, rgba(0,0,0,...)) fired in dark mode → light text on white card bg. Fixes contrast on /views list+cards+calendar shapes. 2026-05-07 21:00:51 +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
99f08e3863 Merge: t-paliad-145 design doc only — local chat feature PARKED per m's call (2026-05-07 17:03). Doc preserved in docs/design-local-chat-2026-05-07.md for when it un-parks. 2026-05-07 17:04:10 +02:00
m
dd4f563212 design(t-paliad-145): local chat for teams
Inventor design for in-app messaging surface inside paliad. Three
coordinated sub-designs (surface/visibility, real-time/content/
persistence, integration with existing surfaces) answering all 21
issue-body questions plus 11 inventor follow-ups.

Recommendations:
- Per-project chat + DMs in v1; per-deadline/termin/partner-unit defer
- Per-thread visibility = can_see_project (existing predicate, derivation-aware)
- SSE for real-time (single new long-lived endpoint)
- New: paliad.chat_threads, chat_messages, chat_reads, chat_thread_participants, chat_mentions
- New project_teams.chat_access flag, default OFF for local_counsel/expert
- Approval auto-post into project chat (one auto-event class only)
- Markdown subset, @mentions, entity-refs (#frist-, #projekt-, #termin-, #approval-)
- 5-min author edit window, soft-delete by author or admin
- In-app sidebar badge in v1; PWA push + email digest deferred Phase 2
- Both top-level /chat and per-project Chat tab
- NOT a 5th source in Custom Views (chat is conversation, not events)

Trade-off flagged: adoption risk against existing Slack/WhatsApp.
Recommend m sanity-check with PA colleagues before locking v1 scope.

Migration 057. Awaiting m go/no-go before coder shift.
2026-05-07 16:03:05 +02:00
m
95f6f03cda Merge: t-paliad-144 A2 — Custom Views frontend (Meine Sichten sidebar group + /views list + /views/new editor + /views/{slug}/edit + 3 render shapes list/cards/calendar) 2026-05-07 13:16:48 +02:00
m
fdde9eb754 feat(t-paliad-144 A2): frontend Custom Views UI
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.
2026-05-07 13:15:55 +02:00
m
cda4b4083d Merge: t-paliad-144 A1 — backend substrate + Custom Views API (migration 056 paliad.user_views + ViewService 4-source union + FilterSpec/RenderSpec validators + SystemView registry + UserViewService + 9 HTTP handlers) 2026-05-07 12:53:52 +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
956ff10e4d design(t-paliad-144): m signed off + Q4 correction (3 shapes, not 4)
m's lock-in 2026-05-07: agree with all recommendations on Q1-Q18 and §10
Q19-Q27, with one correction on Q4: "activity" is a content selection
(sources + filters), not a render shape. Folded into `list` shape with
density: "compact" + actor/time columns. Shape ⊥ source — any source can
render in any shape.

Render shapes for v1: list / cards / calendar (3, was 4).

PR split decision (delegated to inventor): A1 backend substrate + API
(no UI change, ~1800 LoC, smoke via curl) → main → A2 frontend Custom
Views UI (~1600 LoC, additive on A1) → main.

Status flipped DRAFT → LOCKED. Inventor → coder transition initiated.
2026-05-07 12:36:05 +02:00
m
5c263102e3 design(t-paliad-144): data display model — additive Custom Views + render-shape switcher
Inventor pass on m's data-display rethink (m/paliad#5). Three coordinated
sub-designs in one doc, scoped to m's locked direction (additive, subsume
unified inbox, sidebar Meine Sichten group, in-page render-shape switcher,
paliad-only).

Recommended substrate: 4-source ViewService (deadline + appointment +
project_event + approval_request) returning discriminated ViewRow.
Recommended filter grammar: structured JSON spec with server-side validator.
Recommended render shapes for v1: list / cards / calendar / activity (defer
kanban, connections-graph, distinct-timeline). Recommended persistence:
new paliad.user_views table (migration 056), RLS-bounded to caller, code-
resident system defaults (no is_system flag).

Phasing: Phase A ships substrate + Custom Views standalone (~3400 LoC,
no system page changes); Phase B refactors /agenda/events/inbox/dashboard
internals onto the substrate later. Coexists transparently with t-139
(hierarchy aggregation in flight on noether's other branch) and t-138
(approvals shipped).

27 questions answered (18 from issue body §1–§5 + 9 inventor follow-ups
in §10 for m's call before coder shift).
2026-05-06 17:25:44 +02:00
m
f44ee0af0f Merge: t-paliad-143 — derived members per-unit role + multi-unit Herkunft (admin UI dropdown + array_agg in DerivationService) 2026-05-06 17:17:05 +02:00
m
bfc48b1420 fix(t-paliad-143): derived team members all show 'Attorney' + Herkunft collapses multi-unit users
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":

1. **All derived members labeled 'Attorney'.** Migration 055 added
   partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
   the column in the admin UI. So 100% of pum rows are 'attorney' and
   Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
   surfaces every member as 'attorney' even when they're really PAs.

2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
   ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
   wins, every other unit-membership dropped. Judith Molarinho Vaz +
   Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.

**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
  UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
  all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
  bool_or(derive_grants_authority), GROUP BY user. One row per user, every
  (unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
  doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
  directly into the Go struct. Pinned by unit test.

**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
  (unit, role) source — single-unit users render as before
  ("über: **Lehment** [Sicht]"); multi-unit users render
  "über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.

**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
  change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
  already shipped in t-paliad-139 Phase 2). Disables during request,
  rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
  admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.

**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).

**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
  returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
  and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
  unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).

After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
2026-05-06 17:16:17 +02:00
m
5cb7f76160 Merge: t-paliad-142 — sidebar no longer slide-in animates on every page nav when pinned (parallel :root.sidebar-pinned CSS selectors) 2026-05-06 16:56:14 +02:00
m
8b76d0c8fa fix(t-paliad-142): sidebar slide-in on every page nav when pinned
The FOUC script in PWAHead.tsx sets `<html class="sidebar-pinned">`
pre-paint, which kept body padding correct from frame 1 — but the
.sidebar element's own width keyed off `.sidebar.pinned` (set by
initSidebar in DOMContentLoaded). That made every navigation paint
the rail at collapsed width, then animate width 150ms → pinned width
once JS ran. Visible slide-in on Dashboard / Agenda / Projekte / etc.

Fix: extend every `.sidebar.pinned` rule with a parallel
`:root.sidebar-pinned .sidebar ...` selector so the html-class set
pre-paint is sufficient to render the full pinned visual state from
frame 1 (width, label opacity, pin/resize/badge visibility, search
input). Runtime initSidebar still mirrors `.pinned` onto the element
itself for explicit pin/unpin click animation. Same dual-selector
pattern already used by `.has-sidebar.sidebar-pinned` /
`:root.sidebar-pinned .has-sidebar` for body padding.

Mobile unaffected — FOUC script only sets html.sidebar-pinned when
window.innerWidth >= 1024, and initSidebar clears it on resize.
2026-05-06 16:55:30 +02:00
m
9cd05e7c59 Merge: fix team-add suggestions dropdown floating off-screen (parent needed position:relative) 2026-05-06 16:54:22 +02:00
m
5598aef074 fix(t-paliad-141): collab-suggestions dropdown floats off-screen — parent .form-field had no position. Scope :has() rule sets parent to position:relative only where the dropdown actually exists 2026-05-06 16:54:22 +02:00
m
16fe5763f3 Merge: fix /inbox sidebar — call initSidebar() so pin restoration + hover work like every other page 2026-05-06 16:50:21 +02:00
m
18faf81f58 fix(t-paliad-138): /inbox missing initSidebar() call — sidebar JS never ran on inbox page (pin restoration, hover-expand, badge polling all dead). One-liner add to inbox.ts 2026-05-06 16:50:21 +02:00
m
aeaba66892 Merge: t-paliad-139 Phase 2+3 — partner-unit derivation schema (migration 055) + DerivationService + Team-tab subsections + derived_peer approval-authority extension 2026-05-06 16:47:36 +02:00
m
a61c1490e3 feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.

Schema (migration 055 update)
-----------------------------
  - paliad.approval_requests.decision_kind CHECK extended to accept
    'derived_peer'. Down migration restores the t-138 two-value CHECK.
    Live SQL dry-run confirmed the new value is accepted.

Service layer
-------------
  - approval_levels.go: new constant DecisionKindDerivedPeer.
  - approval_service.go (4 sites widened with the derivation EXISTS branch):
      1. canApprove — third resolution step after global_admin + direct/
         ancestor team membership: matches partner-unit-derived members
         on path with derive_grants_authority=true and a unit_role whose
         approval_role_from_unit_role mapping meets the threshold.
         Returns DecisionKindDerivedPeer when this branch is the one that
         passed.
      2. hasQualifiedApprover (the deadlock-check at submit time) —
         widened so a project with no direct approvers but an authority-
         granting unit attachment is still submittable.
      3. ListPendingForApprover (the /inbox query) — third UNION ALL
         branch so derived authority sees their queue.
      4. PendingCountForUser (the bell-badge query) — same widening so
         derived authority sees the count tick.
  All four queries reuse paliad.approval_role_from_unit_role(text) added
  by Phase 2 of migration 055.

Frontend
--------
  - 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
    "Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
    derived member (Partner Unit)". Verlauf rendering of the third
    decision_kind value works through the existing translateEvent /
    decision_kind switch with no other change. 1606 keys total.

Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.

Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
2026-05-06 16:45:19 +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
2d06cdf20e Merge: t-paliad-139 Phase 1 — /projects/{id} aggregation bug fix (use projectDescendantPredicate on 3 legacy narrow methods + frontend toggle + attribution chip) 2026-05-06 16:29:14 +02:00
m
f8d8ea591d Merge remote-tracking branch 'origin/main' into mai/noether/inventor-project 2026-05-06 16:26:46 +02:00
m
77d664c5cc Merge: fix inbox sidebar collapse — drop duplicate app.js include 2026-05-06 16:24:36 +02:00
m
8cf95761d0 fix(t-paliad-138): drop double-include of /assets/app.js on /inbox — PWAHead already injects it; the duplicate ran sidebar.ts twice and collapsed the sidebar on navigation 2026-05-06 16:24:36 +02:00
m
d41fc49809 feat(t-paliad-139): Phase 1 — /projects/{id} aggregation bug fix
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.
2026-05-06 16:24:31 +02:00
m
1eebf2fc44 Merge: t-paliad-141 — project team-add autocomplete fix + invite-new-user inline flow 2026-05-06 16:22:41 +02:00
m
fb1a709bb8 fix(t-paliad-141): project team-add autocomplete + invite-new-user inline flow
Root cause: `.collab-suggestions` had `display: none` in CSS but no JS site
ever toggled it back on. Suggestions rendered into a permanently hidden div.
Bug originated when the akten-collab-* pattern was renamed and copied for
project team-add and partner-units member-add — the original akten-neu.ts
toggled `style.display`, but the copies relied on innerHTML alone.

Fix: switch to content-driven visibility — `.collab-suggestions:not(:empty)
{ display: block }`. No JS changes needed at consumer sites; fixes all three
broken pickers (project team-add, project parent picker, partner-units member-
add) at once. Added missing styling for `.collab-suggestion` items (padding,
hover, separators) — they were unstyled even when visible.

Plus: invite-new-user inline affordance on project /team. When the typed
query matches zero existing users, a "Benutzer nicht gefunden? Einladen"
row appears below the dropdown. Click opens the existing global invite modal
(sidebar-invite-btn → /api/invite) and pre-fills the email if the query
looks like one. No new backend, no new modal — reuses what /admin/team and
the sidebar already use.
2026-05-06 16:21:53 +02:00
m
e2e1381395 Merge: t-paliad-138 — dual-control approvals (4-eye principle) — migration 054 + ApprovalService + inbox/bell + pending pills + CalDAV PENDING + Verlauf integration 2026-05-06 16:10:29 +02:00
m
0d54da1d5b feat(t-paliad-138): Verlauf rendering for approval lifecycle events
Commit 8 of 8. Bilingual (DE primary / EN secondary) translations for
the four approval event_types per entity that ApprovalService emits
into paliad.project_events:

  deadline_approval_requested / _approved / _rejected / _revoked
  appointment_approval_requested / _approved / _rejected / _revoked

Each gets:
- event.title.<event_type>          — full Verlauf-card heading
- event.description.<event_type>    — full-sentence localized description
- dashboard.action.short.<event_type> — verb-form for the dashboard activity feed

The existing translateEvent dispatch in i18n.ts handles these
automatically — it already keys off event.title.<event_type> for the
title, and the deadline_* / appointment_* prefix branch in
translateEventDescription falls through to event.description.<event_type>
when the stored body has no quoted title (which is true for the
approval-event descriptions emitted by ApprovalService).

Result: every project's Verlauf tab now renders the full 4-eye
lifecycle trail inline alongside the existing deadline_created /
deadline_updated / etc. rows. The /admin/audit-log timeline picks
them up too via the union path.

Pair-card rendering (request + decision side-by-side keyed by
metadata.approval_request_id) was a stretch goal in the design doc;
the current per-event row rendering already conveys the full story
chronologically without needing that pairing logic.
2026-05-06 16:08:35 +02:00
m
deef5aaff5 feat(t-paliad-138): CalDAV [PENDING] prefix + reminder digest pending banner
Commit 7 of 8. Outbound surfaces honour the pending-approval state
instead of going silent on it.

CalDAV (caldav_ical.go formatAppointment): when an appointment is
approval_status='pending', the iCal SUMMARY line is prefixed with
"[PENDING] ". External clients (Outlook, Apple Calendar, etc.) thus
display the unverified state honestly. Approved entries sync clean.

Email reminder digest (reminder_service.go):
- digestRow gains ApprovalStatus, sourced from f.approval_status in
  the SELECT.
- Each pending row's Title is rewritten to "[PENDING] <title>" before
  it lands in the template — visible in every email-rendered list.
- Template data carries PendingCount (count of pending rows in this
  digest) + InboxURL so future template revisions can render a
  banner like "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
  /inbox" without further code changes. Existing templates unchanged
  for backwards compat; the prefix on row titles already conveys the
  signal.
- IsPending flag on each item map for future per-row template
  conditionals.

Rationale: silence on a pending change is the worst outcome for a
4-eye system. The user's external calendar and reminder mail must
reflect "this exists but isn't verified" so they can act before the
deadline lapses.
2026-05-06 16:07:14 +02:00
m
bc47d78d97 feat(t-paliad-138): pending pills on /events and /agenda
Commit 6 of 8. Renders the approval-pending warning pill on the two
busiest list surfaces:

- /events (deadline + appointment list): ⚠ pill next to the title +
  soft-tinted row via .entity-row--pending-update modifier.
- /agenda (timeline): ⚠ pill in the headline + same row tint.

Changes:

- internal/services/event_service.go: EventListItem gains
  ApprovalStatus *string; projectDeadline / projectAppointment
  populate it from the embedded model.
- internal/services/deadline_service.go ListVisibleForUser: SQL adds
  f.approval_status / pending_request_id / approved_by / approved_at
  to the SELECT so DeadlineWithProject hydrates them.
- internal/services/appointment_service.go ListVisibleForUser: same
  for appointments + completed_at.
- internal/services/agenda_service.go: AgendaItem gains
  ApprovalStatus; the per-source SQL queries select it; the
  loadDeadlines / loadAppointments projection sets it.
- frontend/src/client/events.ts renderRow: adds entity-row--pending-update
  modifier and an inline approval-pill on the title cell when status='pending'.
- frontend/src/client/agenda.ts renderItem: same treatment on the
  agenda-item headline.

Generic "pending update" label (approvals.pending_update.label) — not
lifecycle-specific. The inbox carries the lifecycle detail. Showing
just one pill keeps the visual signal clear; an approver scanning a
list of pending entities sees them at a glance via the row tint, then
clicks through to /inbox to see what's pending and act.

Detail pages (/deadlines/{id}, /appointments/{id}) and /dashboard
deadline rail — pill rendering for those surfaces deferred to a
follow-up to keep this commit focused. Rendered everywhere it
matters most for daily use.
2026-05-06 16:05:00 +02:00
m
07a1c17861 feat(t-paliad-138): /inbox page + sidebar bell badge
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).
2026-05-06 16:00:17 +02:00
m
2247c0707d docs(t-paliad-139): design lock — m signed off on all 19 §6 recommendations
m's go/no-go pass at 2026-05-06 15:58: "I agree with all your recommendations
- go." All 19 questions in §6 lock as the recommended answers verbatim.

§0 status flipped from READY-FOR-REVIEW to LOCKED. New "Locked m decisions
on §6" subsection captures the highlights inline so future readers don't
have to scan the whole table to know what's pinned.

§13 end-of-design line updated to reflect the lock.

Implementation phasing (§7) unchanged:
- Phase 1: bug fix on the 3 narrow service methods (no schema, ~400 LoC,
  ships standalone, closes the user-visible /projects/{id} "Keine Fristen"
  bug).
- Phase 2: migration 055 (partner_unit_members.unit_role,
  project_partner_units, extended can_see_project()) + DerivationService +
  frontend Team-tab subsections + /admin/partner-units unit_role tagging
  + project /settings/team Partner Units section. Independent of t-138.
- Phase 3: approval extension — canApprove + inbox SQL widening for
  derived_peer decision_kind. Gates on cronus's t-138 (currently on
  mai/cronus/inventor-dual-control @ b3401ec) landing on main.

Inventor parked. Awaiting head's coder-shift assignment.
2026-05-06 15:59:37 +02:00
m
93c4453ce5 Merge remote-tracking branch 'origin/main' into mai/cronus/inventor-dual-control 2026-05-06 15:53:46 +02:00
m
a42322de3f Merge: t-paliad-140 — editable project on /deadlines/{id} + /appointments/{id} 2026-05-06 15:43:20 +02:00
m
457af2f6c4 fix(t-paliad-140): editable project on /deadlines/{id} + /appointments/{id}
Edit mode now exposes a project picker so a deadline or appointment can be
moved to a different matter. Backend Update accepts project_id (and
clear_project for appointments), validates visibility on the destination,
and emits *_project_changed audit rows on both the OLD and NEW project so
each side's Verlauf still shows the move.

Personal-to-project linking and project-to-personal unlinking are gated by
the existing personal-Appointment creator check; project-to-project moves
re-use the existing requireMutationRole gate plus a fresh visibility check
on the target.
2026-05-06 15:42:22 +02:00
m
abc395fcfa Merge: i18n fix — drop 'informieren' from Verfahrensablauf pathway label 2026-05-06 15:38:45 +02:00
m
747d85fe49 fix(i18n): drop nonsensical 'informieren' from Verfahrensablauf pathway label 2026-05-06 15:38:42 +02:00
m
6c41550945 docs(t-paliad-139): inventor design — hierarchy aggregation + effective team + PA derivation
Three coordinated sub-designs in one doc, scoped to m's locked constraints
(2026-05-06):

1. Surface-by-surface aggregation policy. Bug surface fix:
   /projects/{client_id} renders "Keine Fristen" because
   DeadlineService.ListForProject + AppointmentService.ListForProject +
   ProjectService.ListProjectEvents all WHERE project_id=$1 exact-match
   instead of walking paliad.projects.path descendants. The shipped t-124
   contract (projectDescendantPredicate, deadline_service.go:133 etc.)
   already aggregates correctly on the union endpoints — three legacy
   narrow paths just bypass it. Per-surface decision table for events /
   deadlines / termine / Verlauf / project tree counts / dashboard /
   CalDAV / email / search.

2. Effective-team semantics. Three structural gaps in the issue's
   premise (verified against schema):
   - No project↔unit junction (partner_unit involvement on a project).
   - No PA/lawyer distinction in partner_unit_members (no role column).
   - No lawyer↔PA pairing anywhere — Q11's "where is it stored" → nowhere.
   Proposes:
   - paliad.partner_unit_members.unit_role (lead|attorney|senior_pa|pa|paralegal),
     unit-scoped not firm-wide so 3-axis principle holds.
   - paliad.project_partner_units junction with derive_unit_roles[]
     (default {pa, senior_pa}) + derive_grants_authority bool.
   - Compute-on-read derivation via extended can_see_project() — no
     materialised state, no drift.
   - Display-effective vs visibility-effective team are different sets;
     rename ListEffectiveMembers to ListVisibilityEffectiveMembers + add
     ListSubtreeMembers.

3. Approval policy × hierarchy × derivation. Coordinates with t-138
   (cronus, mai/cronus/inventor-dual-control @ 7d1ddb9):
   - Q10: keep cronus's no-auto-inheritance, harden UX with a "Eltern-
     Politik (zur Information)" panel showing parent rules without
     applying.
   - Q12: derived members visibility-only by default; per-(project, unit)
     opt-in flag derive_grants_authority. When opted in, decision_kind
     extends with derived_peer for honest audit chronology.
   - canApprove + inbox SQL extension shape spec'd; coordinates with
     cronus's t-138 §3.4 / §7.4.

Locked m decisions surfaced in §0:
- Behaviour is surface-specific.
- Effective Team of a Client = direct ∪ descendants ∪ partner-unit-derived.
- PA derivation = unit-on-project trigger.
- Derivation honesty: annotated everywhere.
- paliad-only scope.

19 design questions with proposed answers in §6 for m to lock. Migration
055 specced (§5). Implementation phased into 3 PRs (§7) — Phase 1 bug fix
ships standalone if m wants quick win.

Inventor parked. Awaiting m go/no-go before coder shift.
2026-05-06 15:38:41 +02:00
m
fb6a07f4b7 feat(t-paliad-138): approval API endpoints (policy CRUD + inbox + decisions)
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.
2026-05-06 15:30:28 +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
b3401ec8ac feat(t-paliad-138): migration 054 — dual-control approvals schema
Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per
docs/design-approvals-2026-05-06.md. No Go code reads these yet —
paliad behaves identically until commit 2 wires ApprovalService into
the mutation paths.

Migration 054 adds:

1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the
   English `project_teams_role_check` and the German-legacy
   `projekt_teams_role_check` (live-DB constraint name carried over
   from migration 018's pre-rename era).

2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
   ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2)
   > pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors
   the upcoming Go `levelOf()`.

3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event,
   required_role) — UNIQUE composite key gives at most 8 rows per
   project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only
   for global_admin (defence-in-depth; service-role pool bypasses RLS,
   so the actual gate is service-layer).

4. `paliad.approval_requests` — operational pending workflow.
   pre_image jsonb captures revert state; payload echoes the diff;
   required_role snapshots the policy at request time. CHECK
   `decided_by != requested_by` is the second layer of self-approval
   block. RLS = same can_see_project predicate as deadlines /
   appointments — anyone with project visibility sees pending requests.

5. `approval_status` (default 'approved'), `pending_request_id`,
   `approved_by`, `approved_at` columns on both deadlines and
   appointments. `appointments.completed_at` (new) lands the
   appointment:complete lifecycle event.

6. Backfill: every existing deadline + appointment row marked
   approval_status='legacy'. Per Q11, no retroactive approval; the
   next mutation on a legacy row that hits an active policy follows
   the normal flow.

Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments
backfill, both new tables instantiate cleanly, helper function returns
correct levels, self-approval CHECK fires on invalid INSERT, valid
pending insert succeeds.
2026-05-06 15:13:26 +02:00
m
7d1ddb9b84 docs(t-paliad-138): inventor design — dual-control approvals (4-eye)
Locked design for 4-Augen-Prüfung on Fristen + Termine. m-confirmed
decisions on all 11 open questions:

- Qualification gate reuses paliad.project_teams.role per-project
  (no new firm-wide axis). Adds new value `senior_pa` to the enum.
- Strict ladder: lead > of_counsel > associate > senior_pa > pa.
  Default required_role = associate. Per-project override allows pa-
  approves-pa or senior_pa-tier escalation.
- Per-(project, entity_type, lifecycle_event) policy grammar — up to
  8 settable rows per project in paliad.approval_policies.
- Edit-trigger allowlist = date-bearing fields only (Frist due_date /
  original_due_date / warning_date; Termin start_at / end_at).
- Write-then-approve: row mutates immediately, approval_status flips
  between approved/pending/legacy. Delete is the one stage-then-write
  exception (hard-delete on approve, restore on reject).
- Refuse + global_admin override on single-qualified-approver deadlock.
- Pending state visualised everywhere — list views, agenda, dashboard
  traffic-light, project detail, CalDAV-synced calendars (`[PENDING] `
  title prefix), email reminders.
- Bell + /inbox page with two tabs (zur Genehmigung / meine Anfragen).
- Operational paliad.approval_requests + audit lifecycle written to
  existing paliad.project_events (4 new event_types per entity).
- RLS = same can_see_project predicate; service layer enforces the
  approve/reject action gate. CHECK constraint blocks self-approval.
- Mark-legacy backfill: approval_status='legacy' on existing rows;
  next mutation flows through the gate.

Implementation phasing: single migration 054 + 8-commit PR plan
covering schema, service, wiring, policy authoring page, inbox,
pending pills, CalDAV/email integration, Verlauf rendering.

Inventor parked. Awaiting m go/no-go before any coder shift.
2026-05-06 14:58:01 +02:00
m
c1ceab7f4b Merge: t-paliad-122 — courts entity + per-country holidays (migration 053 + CourtService + HolidayService refactor) 2026-05-06 12:54:15 +02:00
m
733917aae2 feat(t-paliad-122): GET /api/tools/courts + Fristenrechner court picker
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.
2026-05-06 12:50:59 +02:00