Commit Graph

104 Commits

Author SHA1 Message Date
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
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
f8d8ea591d Merge remote-tracking branch 'origin/main' into mai/noether/inventor-project 2026-05-06 16:26:46 +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
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
93c4453ce5 Merge remote-tracking branch 'origin/main' into mai/cronus/inventor-dual-control 2026-05-06 15:53:46 +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
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
b54e938bdf feat(t-paliad-136): Phase B — card-click → calc panel → add to project
The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.

Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
  either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
  optional condition flags. Returns rule metadata + computed dueDate +
  originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
  + HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
  t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
  Sommerferien skip both apply automatically. Court-determined rules
  (party='court' or event_type ∈ hearing/decision/order) return
  IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
  hasn't supplied the flag — the UI uses this to render checkboxes;
  toggling recomputes live. With all flags satisfied + alt_duration_*
  present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
  and error paths (skipped without TEST_DATABASE_URL).

Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
  card (only one open at a time). Pill picker (radio chips) appears
  when the card has 2+ rule pills; first preselected. Trigger date
  defaults to today (m's Q3). Flag checkboxes auto-render from the
  rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
  shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
  Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
  /api/projects/{id}/deadlines/bulk with a single-element array using
  source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
  drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
  (Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
  have a single rule to compute.
- Escape collapses the open card.

CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.

UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
2026-05-05 14:04:54 +02:00
m
d22ace1019 feat(t-paliad-136): Phase C — RoP-rigorous tree taxonomy revision
Migration 052 fixes six concept↔leaf mismaps in the v3 seed and adds
three proactive entry leaves under spaetere-schriftsaetze.

1. cms-eingang.gericht.hinweisbeschluss — drop the response-to-
   preliminary-opinion | DE_INF row. DE_INF (LG) has no
   Hinweisbeschluss; the concept lives only in DE_NULL via PatG §83.

2. cms-eingang.gegenseite.upc-inf.klageschrift — drop the notice-of-
   defence-intention | UPC_INF row. UPC has no such rule in the corpus;
   R.23 reaction is captured by statement-of-defence directly.

3. UPC R.221 cost-appeal sequence (m's Q5): three leaves now surface
   BOTH application-for-leave-to-appeal | UPC_COST_APPEAL (sort 100,
   R.221.1, 15 days) AND notice-of-appeal | UPC_APP (sort 200,
   conditional on leave granted, R.220.1). Replaces the wrong notice-of-
   appeal | UPC_COST_APPEAL row that was silently dropping pills.

4. ich-moechte-einreichen.berufung.upc-coa-orders — replace the buggy
   application-for-leave-to-appeal | UPC_APP_ORDERS (no rule for that
   combo) with request-for-discretionary-review | UPC_APP_ORDERS
   (R.220.3).

5. cms-eingang.gericht.anordnung — narrow request-for-discretionary-
   review NULL → UPC_APP_ORDERS. R.220.3 review applies specifically
   to the Anordnungen / 15-day track.

6a. reply-to-cross-appeal coverage: add UPC_APP rows under upc-{inf,
    rev}.berufungsschrift so the reply leaf is reachable when the
    opponent files an Anschlussberufung.

6b. New leaves under ich-moechte-einreichen.spaetere-schriftsaetze for
    proactive entry: r116-eingaben (EPA R.116 final submissions),
    anschlussberufung-upc (R.237), reply-to-cross-appeal-upc (R.238).

NO `RAISE EXCEPTION` coverage gate (m's Q7) — last night's outage was
caused by exactly that pattern in migration 049. Replaced with a Go-
side test in event_category_coverage_test.go that asserts every
category='submission' concept is reachable from at least one leaf
(except the prosecution-only exempt list: filing, request-for-
examination, approval-and-translation). Skipped without
TEST_DATABASE_URL; CI gates on it.

bescheid-mit-frist mapping deferred per m's Q4. Will land separately.

Migration verified via supabase MCP dry-run + ROLLBACK on the live
youpc DB; end-state matches design §3.2-§3.4.
2026-05-05 13:29:47 +02:00
m
b7470d7d77 fix(t-paliad-136): Phase A — filter narrowing carries (concept, proc) tuples
The v3 B1 decision tree filter collapsed each leaf's
(concept_id, proceeding_type_code) tuple list down to a flat concept_id
slice in EventCategoryService.ConceptIDsForSlug, dropping the per-leaf
proceeding constraint. The search service then loaded pills by
concept_id only, so picking a UPC-specific leaf still surfaced DE/EPA/
DPMA pills for any shared concept (Klageerwiderung, Replik, Duplik,
Berufungsschrift). m's repro: choosing CMS-Eingang → Gegenseite →
UPC Verletzung leaked national submissions.

Confirmed via DB: at least 25 leaves were over-broad pre-fix.

Fix carries the tuple set end-to-end via a new subtreeFilter type with
parallel uuid[] / text[] arrays. The matview SQL now uses
unnest($cids, $procs) AS t(cid, pcode) to match each row against the
allowed tuples — a junction row with NULL proc encodes "any proc for
this concept" (used by cross-cutting concepts like Wiedereinsetzung).

EventCategoryService gains AllOutcomes() for browse-all so the root
view also respects junction tuples. allMappedConceptIDs is gone.

Tests: added 5 v4 subtests under TestDeadlineSearch covering m's
repro slug, multi-tuple narrowing, trigger-pill cross-cutting,
forum AND-narrowing, plus an invariant regression gate that walks
every leaf with non-NULL proc and asserts no pill leaks. Skipped
when TEST_DATABASE_URL is unset; existing v3 assertions unchanged.

No schema change. No migration. Ships independently of Phases B/C.
2026-05-05 13:02:09 +02:00
m
63eb5bde6f feat(t-paliad-134): pill ordering + name standardisation + chip dedup
Five m's-bookmark fixes on top of the B1 surface change:

1. Sort proceeding pills inside concept cards by real-world frequency.
   New paliad.proceeding_types.display_order column (m's spec values:
   UPC_INF=10, DE_INF=20, UPC_REV=30, ..., UPC_PI=920, ...). Default
   999 for unmapped legacy codes. Search service surfaces it through
   the deadline_search matview (rebuilt to add the column) and uses
   it as primary key in pillSortKey, replacing the jurisdiction-rank.

2. Name standardisation: -klage → -verfahren on the proceeding-types
   that describe a multi-step process. Specifically:
     UPC_REV  Nichtigkeitsklage              → Nichtigkeitsverfahren
     UPC_APP  Berufung                       → Berufungsverfahren
     DE_INF   Verletzungsklage (LG)          → Verletzungsverfahren (LG)
     DE_INF_OLG, DE_NULL_BGH, DPMA_OPP, DPMA_BPATG_BESCHWERDE,
     UPC_COST_APPEAL, UPC_APP_ORDERS, DPMA_BGH_RB, DE_INF_BGH —
     same -verfahren standardisation.

3. legal_source for rev.defence × UPC_REV: was NULL, leaking the
   internal local_code 'rev.defence' to the UI. Set to UPC.RoP.49.1
   (Defence to Application for Revocation, R.49.1).

4. Frontend renderPill no longer falls back to rule_local_code when
   legal_source is missing — the source span just collapses, so no
   internal slug ever shows up as a "citation".

5. Quick-pick chips refactored to a slug-based array (QUICK_CHIPS) in
   fristenrechner.tsx, single source of truth for both fork-shortcut
   and B2-search-bar rows. Each chip carries data-chip-name-de /
   data-chip-name-en; relabelChips() rewrites visible text per active
   language. Dropped the duplicate "Statement of Defence" chip (same
   concept as "Klageerwiderung"). Each chip now maps to one concept
   slug — Klageerwiderung→statement-of-defence, Berufung→notice-of-
   appeal, Einspruch→opposition, Replik→reply-to-defence,
   Beschwerde→nichtzulassungsbeschwerde, Schadensbemessung→
   application-for-determination-of-damages, Wiedereinsetzung→
   wiedereinsetzung.

Migration 051 uses RAISE WARNING (not EXCEPTION) on coverage gates
per the 049 outage lesson — partial-migration recovery beats whole-
transaction failure. Matview rebuild stays inside the transaction;
RefreshSearchView() on next boot is a cheap no-op.
2026-05-05 11:53:13 +02:00
m
b32cfed37d feat(t-paliad-134): B1 surface — render concept cards beneath decision tree
Pathway B B1 mode previously rendered an empty result area on every
state — the runB1Search() output target was #fristen-search-results,
which lives inside the B2 panel. When B2 is hidden (B1 active), the
results were written into a hidden subtree and never seen.

Changes:
- TSX: add #fristen-b1-results inside #fristen-b1-panel, below the
  cascade button row.
- frontend/fristenrechner.ts: extract renderSearchResultsInto() and
  wirePillClicks(); runB1Search now writes to fristen-b1-results,
  fetches /api/.../search?browse=all when no slug is picked yet (full
  landscape on entry), and applies CSS-driven loading dim with a seq
  guard against out-of-order responses. Hoisted loadAndRenderB1() so
  showBMode("tree") can trigger the tree load on Pathway B entry
  (radio.checked = true does not fire change events).
- backend: SearchOptions.BrowseAll, allMappedConceptIDs() returning
  the union of every concept reachable from any leaf via
  paliad.event_category_concepts, lifted limit ceiling for browse
  modes (default 200, max 500). Handler exposes ?browse=all.
- CSS: shared loading-state styling for fristen-b1-results.
2026-05-05 11:39:30 +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
cc68ab2873 feat(t-paliad-131): Phase B1 — UPC counterclaim cross-flows
Closes m's primary complaint: today's `with_ccr` flag on UPC_INF only
swaps the Replik / Duplik durations. Per UPC RoP R.29 the with-CCR flow
ALSO adds 5–7 new submissions across the claimant / defendant exchange.
Same gap on UPC_REV: Application to amend (R.49.2.a → R.55 = R.32 m.m.)
and Counterclaim for infringement (R.49.2.b → R.50, R.56 cycle) were
entirely missing.

UPC_INF gets a nested `with_amend` flag under `with_ccr` (R.30 amend
is only available with a CCR). UPC_REV gets two parallel independent
flags `with_amend` + `with_cci`; both can be on. Citations verified
against data.laws_contents (youpcdb, UPCRoP).

Migration 041 (waved INSERTs because each subsequent rule references
the prior wave's parent_id):
- Wave 0: 11 new concept rows (counterclaim-for-revocation,
  defence-to-counterclaim-for-revocation, defence-to-application-to-amend,
  reply-to-defence-to-counterclaim-for-revocation,
  reply-to-defence-to-application-to-amend,
  rejoinder-on-reply-to-defence-to-ccr, rejoinder-on-reply-to-amend,
  counterclaim-for-infringement, defence-to-counterclaim-for-infringement,
  reply-to-defence-to-counterclaim-for-infringement,
  rejoinder-on-counterclaim-for-infringement). counterclaim-for-revocation
  also seeded for the search bar even though its rule lives implicitly
  in inf.sod (the with_ccr flag captures it).
- UPC_INF + UPC_REV sequence_orders renumbered to leave gaps (10/20/30…)
  so new cross-flow rows interleave chronologically with the backbone.
- 7 new UPC_INF rules: inf.def_to_ccr (R.29.a), inf.app_to_amend (R.30.1),
  inf.def_to_amend (R.32.1), inf.reply_def_ccr (R.29.d),
  inf.reply_def_amd (R.32.3), inf.rejoin_reply_ccr (R.29.e),
  inf.rejoin_amd (R.32.3).
- 8 new UPC_REV rules: rev.app_to_amend (R.49.2.a), rev.def_to_amend
  (R.43.3), rev.reply_def_amd (R.32.3 m.m.), rev.rejoin_amd (R.32.3 m.m.),
  rev.cc_inf (R.49.2.b), rev.def_cci (R.56.1), rev.reply_def_cci (R.56.3),
  rev.rejoin_cci (R.56.4).

Calculator (services/fristenrechner.go):
- Zero-duration rules now split into 4 buckets, not 2:
    1. parent=nil + non-court → IsRootEvent (existing)
    2. parent=nil + court     → IsCourtSet (existing, e.g. inf.oral when stand-alone)
    3. parent set + court     → IsCourtSet (existing, waypoints)
    4. parent set + non-court → "filed-with-parent" — inherit parent's
       date. NEW. Used by rev.app_to_amend / rev.cc_inf which per
       R.49(2) are filed AS PART OF the Defence to revocation.
- AnchorOverrides on a zero-duration rule short-circuits to the user's
  date, propagating downstream as before.

Frontend:
- New checkboxes inf-amend-flag (UPC_INF, nested under ccr-flag),
  rev-amend-flag, rev-cci-flag (UPC_REV). Visibility per proceeding
  type; inf-amend disabled until ccr is on (R.30 dependency).
- Three new i18n keys (DE+EN). Small CSS for nested-checkbox indent
  and disabled-state colour.

Live-verified via curl on paliad.de against tester@hlc.de:
  UPC_INF + with_ccr+with_amend, trigger 2026-05-04 → all 7 new rules
  render at correct dates (R.29.a 2mo, R.30.1 2mo, R.32.1 2mo from
  app_to_amend, R.29.d 2mo from def_to_ccr, R.32.3 1mo, R.29.e 1mo,
  R.32.3 1mo).
  UPC_REV + with_amend+with_cci → rev.app_to_amend / rev.cc_inf show
  rev.defence's date (filed-with-parent), R.43.3 2mo / R.56.1 2mo /
  R.32.3 + R.56.3 1mo / R.32.3 + R.56.4 1mo all line up.
2026-05-05 01:25:03 +02:00
m
78966ec098 feat(t-paliad-131): Phase A — concept layer + AnchorOverrides + click-to-edit dates
PR-1 of the Unified Fristenrechner. Purely additive: new search-grouping
layer + per-rule date override capability. No coverage changes yet
(those land in PR-2 = Phase B1 UPC counterclaim cross-flows).

Migrations:
- 037: paliad.deadline_concepts (id, slug, name_de/en, aliases text[],
  party, category, sort_order). Trigram + GIN indexes for the search bar.
- 038: deadline_rules.concept_id (uuid FK), legal_source (text);
  event_deadlines.legal_source; trigger_events.concept_id (text slug,
  soft-link — youpc imports keep their bigint PK).
- 039: deadline_rules.condition_flag text → text[] (USING ARRAY[old]).
  Semantic: rule renders iff every element is in CalcOptions.Flags.
  Single-element arrays preserve the legacy with_ccr swap exactly.
- 040: seed 30 concept rows + backfill all 74 fristenrechner deadline_rules
  with concept_id; backfill legal_source from existing rule_code
  (e.g. 'RoP.023' → 'UPC.RoP.23.1', '§ 276 ZPO' → 'DE.ZPO.276.1',
  'Art. 108 EPÜ' → 'EU.EPÜ.108', 'R. 79(1) EPÜ' → 'EU.EPC-R.79.1').

Calculator (services/fristenrechner.go):
- ConditionFlag is now pq.StringArray (matches text[] schema). New
  allFlagsSet() helper gates rule rendering; rules with multi-element
  flags require ALL of them set (prep for Phase B1 with_amend ∧ with_cci).
- CalcOptions.AnchorOverrides map[string]string (rule_code → YYYY-MM-DD).
  The tree-walk consults overrideDates[parent.code] before reading the
  computed-date map; lets a downstream rule re-anchor on a user-set date.
- IsCourtSet rows that get an override stop being placeholder and emit
  the user's date as a real anchor (so downstream cost_app etc. compute
  off it). New IsOverridden flag in UIDeadline so the UI can highlight
  user-edited rows.
- LegalSource surfaced on UIDeadline for future search-card display.

UI (frontend/src/client/fristenrechner.ts + global.css + i18n):
- Each timeline / column rule date is click-to-edit. Click → inline
  date input → blur or Enter → POST with anchorOverrides → re-render.
  Empty value clears the override. Escape cancels. Root-event rows
  (the trigger anchor) stay non-editable — that's the trigger-date input.
- Override map cleared on proceeding switch / reset; persists across
  trigger-date / flag toggle changes within the same proceeding.
- New CSS: subtle hover underline on .frist-date-edit; lime border on
  .timeline-date--overridden + .frist-date-edit-input.
- New i18n key deadlines.date.edit.hint (DE + EN).

Handler (handlers/fristenrechner.go):
- POST body gains optional anchorOverrides map<string,string>; passed
  through to CalcOptions.

Tests:
- TestAllFlagsSet covers single-flag legacy semantic, two-flag AND
  semantic, empty-required unconditional, extra-flags-no-effect.
- Existing TestIsCourtDeterminedRule unchanged.

Phase A ships standalone — Phase B1 (UPC counterclaim cross-flows) and
Phase C/D (search backend + concept-card UI) follow.
2026-05-05 00:05:12 +02:00
m
9919e04657 feat(t-paliad-128): /events 'Nur persönliche' = items I created
Redefines the "Nur persönliche" filter on /events from "appointment with
NULL project_id" to "items where created_by = me", applied uniformly to
deadlines and appointments.

Before: client-side filter dropped every deadline row because the type
guard was `x.type === "appointment"`. m saw zero deadlines under "Nur
persönliche" even though he created plenty.

After:
- /api/events?personal_only=true (and /api/events/summary?personal_only=true)
  narrow BOTH rails to f.created_by / t.created_by = current user.
  ProjectID is ignored when personal_only is set (the two are
  contradictory).
- DeadlineService.ListFilter and AppointmentService.AppointmentListFilter
  gain CreatedBy *uuid.UUID — composes with existing visibility (AND), so
  a row created on a team the user has since left still won't leak.
- Frontend drops the client-side filter; sends personal_only=true when
  projectFilter === PERSONAL. URL ?personal_only=true also accepted on
  initial load (bookmark-friendly alias for ?project_id=__personal__).
  Personal option now shows for type=Fristen too — applies uniformly.
- 3 new live subtests covering personal_only across type=deadline /
  appointment / all, with mixed-creator + multi-project + null-project
  fixtures.
2026-05-04 19:49:37 +02:00
m
4d7c74994a feat(t-paliad-125): sort project pickers by tree path with depth indent
The /events Project filter dropdown was sorted by `updated_at DESC`, so a
recently-touched Case appeared above its parent Client and cousins
interleaved unrelated branches — m's report (2026-05-04): "Siemens cases
come directly after 'mandant vs Gegner' and are not under 'Siemens-AG'".

Backend: switch ProjectService.List to ORDER BY p.path so every
descendant immediately follows its ancestor — the same ordering BuildTree
produces. Both callers (handleListProjects, searchProjects) gain a
stable, hierarchical default that matches user expectation.

Frontend: add project-indent.ts shared helper and apply NBSP indent
prefix in every <select> picker fed by /api/projects: events filter,
/deadlines/new, /appointments/new, checklist new-instance modal,
Fristenrechner save modal. NBSP avoids browser whitespace collapse
inside <option> labels. Multi-parent repetition is out of scope (data
model has singular parent_id today).

Tests: project_list_order_test pins the path-order contract against a
seeded mixed-recency tree.
2026-05-04 19:30:37 +02:00
m
062630ca38 Merge: t-paliad-123 — /events Status filter for appointments (date buckets) 2026-05-04 18:58:09 +02:00
m
8123d71d08 Merge: t-paliad-124 — project filter walks descendants (Client filter → all child rows) 2026-05-04 18:58:04 +02:00
m
a69fff73e9 feat(t-paliad-124): project filter includes descendant projects
Selecting a Client in the project filter now returns rows attached to
that Client AND every Litigation / Patent / Case below it (and so on
down the tree). Previously the filter was exact-match: picking a Client
hid every item in the subtree, which was the opposite of what users
expect when they pick a parent in a hierarchical picker.

The descendant set comes from paliad.projects.path - every project's
path always contains its own id and every ancestor's id, so any project
whose path includes the filter UUID is either that project or a
descendant. Pattern matches the existing visibility predicate (which
walks the path UPWARD for inheritance); the new helper just inverts the
direction.

Filter sites updated:
  - DeadlineService.ListVisibleForUser     (/deadlines, /events)
  - DeadlineService.SummaryCounts          (deadline summary cards)
  - AppointmentService.ListVisibleForUser  (/appointments, /events)
  - EventService.deadlineBuckets           (/events deadline rail)
  - EventService.appointmentBuckets        (/events appointment rail)

ListForProject (deadline/appointment/checklist/note) is unchanged - it
fetches items for ONE specific project on the project detail page, not
a filter.

Visibility predicate (paliad.can_see_project) untouched - that walks
upward and is a different concern.
2026-05-04 18:57:06 +02:00
m
1bba9cb3ce feat(t-paliad-123): apply date-bucket Status filter to appointments
Until now, /events hid the Status dropdown when Type=Termine. The
date-bucket filters (Heute, Diese Woche, Nächste Woche, Später) only
worked on the deadline rail — m wanted them on appointments too, even
without a "completed" dimension.

Frontend (events.ts):
- New populateStatusFilter() rebuilds the Status <select> options based
  on currentType: deadlines get the full 8-option set, appointments
  narrow to 5 (Alle + 4 buckets). The "completed/pending/overdue"
  options drop because they have no appointment analogue.
- applyTypeVisibility() no longer hides the Status filter for
  appointments; it calls the populator instead. The populator runs on
  type-chip click and on language change so labels translate live.
- When switching type while a now-invalid status is selected (e.g.
  Termine + status=completed via URL), the populator falls back to the
  per-type default (deadline → pending, appointment → all) and updates
  URL params.
- syncURLParams + isFilterPristine + initFilters use a per-type default
  so the appointment view treats `all` as pristine and stays out of the
  URL until the user picks a bucket.
- loadList always sends `status` to /api/events; backend already
  applies bucket-aware appointment filtering via
  bucketAppointmentWindow().

events.tsx:
- The static <option> list collapses to a single placeholder; the
  populator owns the option set at hydration.

i18n:
- New `events.filter.status.all` ("Alle"/"All") for the appointment-only
  case — `deadlines.filter.all` says "Alle (offen & erledigt)" which is
  wrong for appointments (they don't have a completed/pending state).

Backend (event_service_test.go):
- Three new live subtests confirming type=appointment + status=today
  narrows to today's appointments, status=later narrows to far-future,
  and status=completed collapses the appointment rail (defensive vs.
  URL-hacking — the dropdown excludes that value for appointments).
2026-05-04 18:56:25 +02:00
m
7461c4af49 fix(t-paliad-121): stop shifting deadlines for UPC court vacations
Per UPC AC decision 2023-05-26, the UPC has summer + winter judicial
vacations but the Court continues to operate during them — they do NOT
extend procedural deadlines. paliad's HolidayService was treating every
paliad.holidays row as a non-working day, including vacation entries, so
a deadline landing on Tue 2026-08-04 (a regular working Tuesday) was
incorrectly shifted to Mon 2026-08-31 by walking the entire summer-
vacation run.

Fix: gate IsNonWorkingDay on Holiday.IsClosure (true for public_holiday
and closure types, false for vacation). IsHoliday still returns the row
regardless of type — UI surfaces that want to flag "this date is inside
UPC vacation" can still ask. paliad.holidays data is unchanged: the UPC
vacation rows stay as informational metadata.

The Kind="vacation" branch of AdjustForNonWorkingDaysWithReason is now
unreachable in practice (every vacation entry is IsClosure=false, so the
walk loop never enters with a vacation as the cause). Left in place as
defensive code for any future vacation type that should shift.

Tests: replaced TestAdjustForNonWorkingDaysWithReason_Vacation (asserted
the old wrong behaviour) with TestVacationDoesNotShiftDeadlines covering
m's reproduction (Tue 2026-08-04 → no shift), winter-vacation no-shift
(Mon 2026-12-28), Christmas/Neujahr regression (still shift correctly,
and walk through informational vacation entries to land on the next
real working day), and a Karfreitag regression to lock public-holiday
shifts.
2026-05-04 18:48:23 +02:00
m
d688ebde90 feat(t-paliad-119): explain WHY a Fristenrechner deadline was shifted
The current "Wochenende/Feiertag" / "weekend/holiday" label hides the cause
of long shifts — m's reproduction had a deadline jump from 4.8.2026 to
31.8.2026 (+27 calendar days) across UPC Summer Vacation, and the UI made
it look like a bug. The math was correct; the explanation was lying.

Backend:
- AdjustForNonWorkingDaysWithReason returns an AdjustmentReason alongside
  the adjusted date. Walks the same 60-iter loop, classifies the dominant
  cause (vacation > public_holiday > weekend), collects every named
  holiday hit, and for vacations scans outward to report the contiguous
  block boundary (27.7.–28.8., not the 25 individual rows).
- AdjustForNonWorkingDays now wraps the new method, preserving its
  3-tuple signature for existing callers (deadline_calculator,
  event_deadline_service).
- UIDeadline gains an AdjustmentReason field; FristenrechnerService
  populates it on every shifted deadline.
- Date fields serialise as YYYY-MM-DD strings (HolidayDTO + string
  vacation span) — the Fristenrechner client already speaks that format.

Frontend:
- AdjustmentReason → human-readable phrase via renderAdjustmentReason:
    vacation       → "{vacation_name} ({span})"
    public_holiday → "Feiertag ({first_holiday_name})" / "{name} holiday"
    weekend        → "Wochenende" / localised weekday
- Surrounding format becomes "Verschoben wegen X: A → B" (DE) or
  "Shifted (X): A → B" (EN). Falls back to the legacy reason string
  when the backend hasn't sent a structured reason.
- Vacation names render verbatim from paliad.holidays — no hardcoded
  i18n mapping for individual closures (those rotate via the seed, not
  via i18n.ts).

Tests cover the three Kind paths plus the no-shift case; UPC vacation
test injects the migration-010 seed into the cache so the assertion
runs without a live DB.

Out of scope (raised in conversation, deferred):
- Whether "UPC Summer Vacation" / "UPC Winter Vacation" are the right
  names for the seeded rows, and whether the winter block belongs in
  paliad.holidays at all (m flagged this as BS while reviewing the
  task — needs a data-side decision before renaming/removing).
- holidays.country isn't filtered by proceeding-type jurisdiction, so
  UPC vacation currently shifts EP_GRANT / EPA_APP / German national
  deadlines too. Bigger fix; flagged for a follow-up issue.
2026-05-04 18:31:55 +02:00
m
4e1213fbd1 fix(t-paliad-116): event_deadlines i18n follow-up — title_de backfill + notes_en
Two adjacent i18n leaks in /tools/fristenrechner "Was kommt nach…" mode,
same pattern as t-paliad-112's deadline_rules fix but on event_deadlines:

A) title_de empty for all 70 rows. The DTO already falls back to title
   silently, so DE locale rendered English titles ("Statement of Defence",
   "Decision of the EPO", …). Backfilled via mig 035 with UPC RoP DE
   terminology that matches the trigger_events.name_de translations from
   mig 033, so the picker label and the deadline row read the same.

B) notes column carries English text on rows 50, 52, 70 (DE-named column
   was DE-only in spec, but seeds slipped EN strings through). Mig 036
   adds a parallel notes_en column following the t-112 mig 032 pattern,
   copies the existing English into notes_en, and replaces notes with
   proper DE for those three rows.

Render path:
- service: select notes_en, plumb through EventDeadlineResult.NotesEN
- frontend: getLang() === "en" ? (notesEN || notes) : notes (mirrors the
  proceeding-tree timeline branch already in fristenrechner.ts)
2026-05-04 17:03:58 +02:00
m
c554e865eb Merge: t-paliad-111 — bug bundle correctness (UPC GESAMTKOSTEN, court-set dates, REGEL save) 2026-05-04 14:42:51 +02:00
m
0be2dfb5a0 fix(t-paliad-111): bug bundle (correctness) — UPC GESAMTKOSTEN, court-set dates, REGEL save flow
Three correctness bugs from the t-paliad-101 QA sweep, fixed together since
they all change displayed/saved numbers users rely on.

B1 — Kostenrechner UPC GESAMTKOSTEN double-count
  ComputeUPCInstance was setting InstanceTotal = effectiveCourtFee +
  recoverableCeiling. The R.152 recoverable-cost cap is the OPPOSING
  side's worst-case loss-of-suit liability, not the user's own cost —
  folding it into GESAMTKOSTEN inflated the UPC total under a label
  that means "your outlay," and the DE LG/OLG/BGH branches don't add
  any opponent estimate. Drop it from InstanceTotal; the ceiling
  still surfaces as its own RecoverableCeiling line item.

  Live pre-fix on paliad.de (Streitwert 100k, UPC 1. Instanz only):
    instanceTotal = 52600 = 14600 court fee + 38000 R.152 ceiling
  Post-fix:
    instanceTotal = 14600 (court fee only); RecoverableCeiling stays 38000

B3 — Court-determined Termine emit trigger date as a real-looking date
  Zwischenverfahren / Mündliche Verhandlung / Entscheidung all live in
  paliad.deadline_rules with duration_value=0 and parent_id=NULL, so
  Calculate() classified them as IsRootEvent and emitted the trigger
  date as their own DueDate. Worse, RoP.151 "Antrag auf Kostenentscheidung"
  parents off inf.decision and chained 1 month off the placeholder ->
  bogus deadline that the UI rendered as real.

  Fix: classify a zero-duration rule as IsCourtSet (not IsRootEvent)
  when primary_party = 'court' or event_type ∈ {hearing, decision,
  order}. Track court-set rule IDs and propagate IsCourtSet downstream
  to any rule whose parent is court-set, so RoP.151 also surfaces as
  court-set rather than a fabricated date. Save-modal already greys
  out IsCourtSet rows so the "Gerichtsbestimmte Termine ohne Datum
  werden übersprungen" footnote becomes truthful again.

  Live pre-fix on paliad.de (UPC_INF, trigger 2026-04-29):
    Zwischenverfahren / Oral / Entscheidung -> dueDate 2026-04-29
    Antrag auf Kostenentscheidung -> 2026-05-29 (bogus, +1mo from trigger)

B6 — Fristenrechner save flow stored rule code in TITLE
  Frontend was concatenating "RoP.023 — Klageerwiderung" into the
  title because deadlines had nowhere else to put the citation, and
  the /deadlines REGEL column ended up showing "—". Add migration 032
  with a paliad.deadlines.rule_code text column, plumb it through
  CreateDeadlineInput / insertTx, drop the now-redundant r.code AS
  rule_code JOIN alias on the list query (the deadline owns its
  citation), and render f.rule_code on the project-detail deadlines
  table + /deadlines events list + deadline-detail page.

Build, vet, and tests all clean. New unit test
TestIsCourtDeterminedRule pins the B3 discriminator across the
event_type / primary_party combinations seen in migrations 012 + 031.

Repro creds: tester@hlc.de
2026-05-04 14:42:29 +02:00
m
341fa6c26f fix(t-paliad-112): i18n leaks — deadline_notes_en, trigger-event DE, Checkliste header
Three i18n bugs from the t-paliad-101 QA sweep, fixed together:

B2 — Fristenrechner deadline notes leaked German into the EN locale.
Migration 032 adds paliad.deadline_rules.deadline_notes_en (TEXT NULL)
and backfills English translations for all 30 rules that carry a
deadline_notes value (UPC RoP / EPC / ZPO terminology). The frontend
prefers _en when locale=EN and falls back to deadline_notes (DE) when
the column is NULL, so future seeds without an EN translation render
in DE rather than empty. UIDeadline DTO gains notesEN. The bulk
"Als Frist(en) speichern" CTA now stores the locale-matched note text
so EN users get an EN note alongside the EN title.

B8 — trigger-event picker labels were English-only when DE locale was
active (102 rows, name_de defaulted to '' in 028, frontend already had
the locale switch but no data). Migration 033 backfills name_de for
all 102 trigger events using standard German UPC RoP terminology
(Klageschrift, Klageerwiderung, Replik, Duplik, Nichtigkeitswiderklage,
Verletzungswiderklage, Berufungsschrift/-begründung, Anschlussberufung,
Schutzschrift, Beweissicherung, etc.).

S3 — frontend/src/client/checklists-instance.ts:154 had a hardcoded
"Project" label in both branches of the locale ternary; the DE branch
now reads "Projekt", matching the surrounding meta-item labels' pattern
(Court / Authority → Gericht / Behörde, Reference → Rechtsgrundlage).
2026-05-04 14:36:50 +02:00
m
57237a55a3 feat(t-paliad-110): refactor Dashboard rails — drop Erledigt card, add Später + Termine rail
PR-4 of the Fristen+Termine unification, closing out t-paliad-110.

Fristen rail (was 5 cards):
- Erledigt card removed (status=completed stays reachable via the EventsPage
  filter dropdown — no card on the rail per the new model)
- Später card added (pending deadlines past Mon-week-after, click filters
  to /deadlines?status=later)
- 4+1 final shape: Überfällig (conditional alarm) · Heute · Diese Woche ·
  Nächste Woche · Später

Termine rail (new): 3 cards — Heute · Diese Woche · Später. No Überfällig
(past appointments aren't urgent), no Nächste Woche (low-value distinction
for appointments per the design rationale). Cards click through to
/appointments?status=… so users land in the matching EventsPage view.

Backend (DashboardService.loadSummary):
- DeadlineSummary.CompletedThisWeek dropped, .Later added
- AppointmentSummary added (Today / ThisWeek / Later)
- One CTE-based query computes both rails alongside MatterSummary; bucket
  cutoffs share computeDeadlineBucketBounds with /api/events/summary +
  /api/deadlines/summary so all three surfaces stay in lockstep

Frontend:
- dashboard.tsx: Erledigt card removed, Später card + Termine section added
- client/dashboard.ts: types updated, renderAppointmentSummary added
- 4 new i18n keys (DE+EN): dashboard.summary.later +
  dashboard.appointment_summary.heading
- CSS: .dashboard-card-later (muted blue) + 3 .dashboard-card-appt-* rules
  reusing the existing --bucket-* tokens

go build/vet/test ./... clean. bun run build clean (1396 keys).
2026-05-04 13:52:49 +02:00
m
fe9c1b7de2 feat(t-paliad-110): add shared EventsPage component + bucket-aware backend tweaks
PR-2 of the Fristen+Termine unification. Pure additive change — the existing
deadlines.tsx + appointments.tsx pages stay live; this PR introduces the new
events.tsx shell + client/events.ts runtime that PR-3 will mount onto the
two routes.

Frontend (new):
- frontend/src/events.tsx — shared shell with the 3-chip type toggle
  (Fristen / Termine / Beides), the 5-card summary row (Überfällig
  conditional + 4 universal cards), the union filter row, and the unified
  table that renders a discriminated row per type. Two header CTAs ("Neue
  Frist" + "Neuer Termin") collapse to the relevant one in single-type mode.
- frontend/src/client/events.ts — runtime. Reads window.__PALIAD_EVENTS__
  (PR-3 will inject defaultType from the Go handler), derives the rest from
  ?type query param. Card click sets status filter; the events endpoint
  takes care of bucket-aware appointment-side date windowing so both rails
  stay in sync in Beides mode. Hide-on-uniform pattern applied per column
  (rule, event_type, location, appointment_type, status, row-type chip).
- frontend/build.ts — emits events-deadlines.html + events-appointments.html
  from one renderEvents(currentPath) so each output gets the right Sidebar
  highlight; client/events.ts bundle added.
- 16 i18n keys (DE+EN): events.toggle.*, events.summary.later,
  events.col.*, events.row.type.*, events.empty.*, events.unavailable plus
  the new deadlines.summary.later / deadlines.filter.later pair for the
  Später bucket.
- CSS: --bucket-later (#1d4ed8 light / #60a5fa dark) for the Später card,
  matching events-table--hide-* column hiders, .events-row-type-chip
  styling, .event-type-chip-row spacing.

Backend tweaks (small):
- DeadlineFilterLater (`later`): pending deadlines past Mon-week-after.
  Click-target for the Später card.
- EventService.ListVisibleForUser now derives an appointment-side date
  window from a bucket-style status (today/this_week/next_week/later) so
  card clicks filter both rails consistently. Overdue/Completed exclude
  appointments entirely (no appointment analogue).
- pickLater / pickEarlier helpers intersect the bucket-derived window with
  any caller-supplied from/to.

go build/vet/test ./... clean. bun run build clean (1394 keys, IIFE prologue
guard passes).
2026-05-04 13:46:33 +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
37a925d3b2 feat(t-paliad-106): harmonize deadline summary — 5 disjoint buckets across Dashboard + Fristen
Both surfaces now show the same buckets with the same labels and the same
cutoffs: Überfällig (conditional, alarming) · Heute · Diese Woche ·
Nächste Woche · Erledigt. Single-source bucket math via
computeDeadlineBucketBounds — Heute = today, Diese Woche = tomorrow
through the upcoming Sunday inclusive, Nächste Woche = next Monday
through next Sunday inclusive, all disjoint. Items past next Sunday
are visible only via "All open"/"Upcoming" filters; the Überfällig
card stays hidden when count == 0 and switches to a saturated red
pulse + bold white text when count > 0.

Filter dropdown on /deadlines gains today / next_week entries; old
"upcoming" filter still works as a back-compat alias for everything
pending past this Sunday so legacy bookmarks don't 4xx.

Tests: 8 deterministic table cases for the bucket pivots (every
weekday + a 21-day disjointness walk).
2026-05-04 12:03:56 +02:00
m
95a6df5b49 feat(t-paliad-102): link Verlauf entries to deadlines/appointments/notes
Extends the t-paliad-097 metadata pattern from checklist_* events to the
remaining audit families. Project Verlauf and Dashboard activity feed now
deep-link each event to its originating entity:

  - deadline_{created,updated,completed,reopened} → /deadlines/{id}
  - appointment_{created,updated} → /appointments/{id}
  - note_created → /appointments/{id} | /deadlines/{id} | /projects/{id}
    (most-specific parent — notes have no standalone page)

Backend (Go):
  - deadline_service.go / appointment_service.go: switch single-entity
    mutation events from insertProjectEvent to insertProjectEventWithMeta
    carrying {"deadline_id"|"appointment_id": uuid}.
  - note_service.go:insertWithAudit: derive metadata from noteParent so
    the audit row records {note_id, deadline_id|appointment_id|project_id}.

Frontend (TS):
  - projects-detail.ts: extract eventDetailHref(); wrapEventTitleLink
    delegates to it. Comment block lists every wired event family.
  - dashboard.ts:activityHref: same routing rules as the project Verlauf.
  - global.css: .entity-event becomes position:relative; the
    .entity-event-link::before pseudo expands the link's hit area to the
    full card so a click anywhere on the row navigates (matches what m
    expected from "die Karte ist verlinkt"). Hover lifts border + shadow.

Excluded by design (mirrors checklist_deleted exclusion):
  - *_deleted events — entity is gone.
  - deadlines_imported — bulk event with no single deadline_id; would
    need an aggregate target the product doesn't have today.

Pre-metadata rows stay non-clickable (no backfill — same precedent as
t-paliad-097).
2026-05-03 18:39:06 +02:00
m
df321acb63 feat(t-paliad-097): clickable checklist references + Vorhandene Instanzen tab
Two related checklist UX gaps:

1. Checklist events in a project's Verlauf tab were unclickable — and
   nothing in the project_events row carried the originating instance ID.
   Add an `insertProjectEventWithMeta` helper, write
   {"checklist_instance_id": <uuid>} as project_events.metadata for
   checklist_created / _renamed / _linked / _unlinked / _reset (skipped
   for _deleted — instance is gone). Surface metadata on
   /api/projects/{id}/events and on dashboard recent_activity. The
   Verlauf renderer wraps the title in <a href="/checklists/instances/{id}">
   when metadata.checklist_instance_id is present, and the dashboard's
   activity feed deep-links the project ref to the instance directly for
   checklist_* events. Existing rows (metadata `{}`) stay non-clickable —
   no migration backfill needed.

2. /checklists previously demanded a template pick before any existing
   instance was reachable. Add a tab nav (Vorlagen / Vorhandene Instanzen)
   using the existing entity-tab pattern. New endpoint
   GET /api/checklist-instances and ChecklistInstanceService.ListAllVisible
   return every visible instance across templates + projects, joined with
   project ref/title and sorted by created_at DESC. Rows show template,
   instance name (linked), project link (or "Persönlich"), progress bar,
   and created date. URL state (?tab=instances) keeps the active tab
   shareable. EN + DE i18n covered for tab labels and column headers.

Also adds event.title.checklist_* localizations for the Verlauf header
that translateEvent looks up.
2026-05-01 09:48:25 +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
fca7143244 feat(t-paliad-089): admin Event-Type moderation panel
Q6 of t-paliad-088 left firm-wide event_type creation open to any user; this
ships the moderation surface admins use to dedupe and clean up the resulting
drift.

Service layer (internal/services/event_type_service.go):
- ListAllForAdmin(filter) — firm-wide rows with usage_count and
  author_display_name, optionally including archived (single query, scalar
  subquery + LEFT JOIN paliad.users). Sorted live-first, then category +
  label_de.
- ListPrivatePendingPromotion — every private non-archived row across all
  users, sorted by usage_count DESC.
- ArchiveBulk(ids) — UPDATE archived_at=now() WHERE is_firm_wide AND NULL.
- Promote(id) — flip is_firm_wide=true; surfaces ErrEventTypeSlugTaken on
  collision so the admin can merge instead.
- Restore(id) — flip archived_at back to NULL; same slug-collision surface.
- MergeIDs(winner, losers) — tx-scoped INSERT … SELECT … ON CONFLICT
  redirect of deadline_event_types from losers → winner, then DELETE on the
  loser junction rows, then archive the losers. Refuses if the winner is
  archived or private. Junction PK does the dedup.
- requireAdmin gate runs at every method (defence-in-depth on top of the
  handler-level RequireAdminFunc).

Handlers (internal/handlers/admin_event_types.go):
- GET /api/admin/event-types[?include_archived=1]
- GET /api/admin/event-types/private
- POST /api/admin/event-types/archive { ids:[…] }
- POST /api/admin/event-types/merge { winner_id, loser_ids:[…] }
- POST /api/admin/event-types/{id}/promote
- POST /api/admin/event-types/{id}/restore
- GET /admin/event-types page shell.
All wrapped behind auth.RequireAdminFunc at registration time.

Frontend:
- New /admin/event-types SPA (admin-event-types.tsx + client/admin-event-types.ts):
  search, "Archivierte anzeigen" toggle, per-row archive/restore, bulk
  archive, merge modal (winner picker defaults to highest-usage row),
  separate table for private types pending promotion.
- Sidebar entry under Verwaltung; admin landing card.
- ~50 i18n keys DE+EN under admin.event_types.* + nav.admin.event_types.
- CSS for archived badge, merge option list, bulk-actions bar.

Out of scope (deferred): public "merge request" workflow for non-admins.
2026-04-30 16:37:48 +02:00
m
909167b036 refactor(t-paliad-091): NoteService consolidation — push CanSee to parent services
F-3 from t-paliad-074 architecture audit. NoteService used to call
ProjectService.GetByID and AppointmentService.GetByID just for the
visibility bit (~6 cross-service full-row reads in note_service.go).
Each was a full SELECT on the parent row when only a boolean was needed.

Add CanSee(ctx, userID, id) (bool, error) on the two parent services:
single EXISTS round-trip, no projection. Personal Appointments stay
visible only to their creator; project-anchored Appointments inherit
the project's visibility predicate (global_admin shortcut + team-walk).

NoteService gains two private helpers — requireProjectVisible and
requireAppointmentVisible — that wrap CanSee + ErrNotVisible. All
visibility-only sites in note_service.go (ListForProject /
ListForDeadline / ListForAppointment / ListForProjectEvent /
CreateForProject / CreateForDeadline / requireVisible) now go through
the helpers.

CreateForAppointment keeps appointment.GetByID — it legitimately needs
the appointment's project_id for the audit-event row.

DeadlineService.CanSee was not added: note_service never reaches into
the deadline service for visibility (it does its own SELECT project_id
FROM paliad.deadlines and gates via the project predicate).

Test: cansee_test.go covers the gate level for both new methods —
admin sees everything (global_admin shortcut), team member sees their
team's, non-member sees nothing, missing IDs are invisible to all,
personal appointments are private to creator.
2026-04-30 16:18:49 +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
d00974424f fix(t-paliad-086): Tier 1 Fristenrechner bug fixes — PR-3
Implements the four audit recommendations from §6.1 of
docs/audit-fristenrechner-completeness-2026-04-30.md plus a holiday-
adjustment cap fix surfaced by PR-2's smoke test.

(1) UPC_INF CCR-conditional rejoinder
   Public Fristenrechner now flips inf.reply (RoP.029.b → RoP.029.a) and
   inf.rejoin (1mo / RoP.029.c → 2mo / RoP.029.d) when the user ticks
   "Mit Widerklage auf Nichtigkeit." Implemented via a new
   `condition_flag` column on paliad.deadline_rules: when the rule names
   a flag and the API request's flags array contains it, the calculator
   substitutes alt_duration_value/unit and alt_rule_code. Independent of
   the existing `condition_rule_id` mechanism (which references a real
   rule in the same proceeding tree — only useful for matter-attached
   trees that already seed the CCR rule).

(2) UPC_APP / internal APP grounds anchoring
   `app.grounds` is now anchored on the trigger date (the appealed
   decision) with a 4-month duration, not chained 2mo after `app.notice`.
   Per RoP 220.1 the legal rule is "4 months from notification of the
   decision," independent of when the notice itself was filed. The chain
   only happened to give the right answer when both legs landed on a
   working day; under holiday rollover (e.g. notice deadline pushed to
   Monday) the grounds deadline drifted off the 4mo legal target.

(3) EP_GRANT publish anchor on priority date
   New `anchor_alt` column on paliad.deadline_rules. ep_grant.publish
   carries `anchor_alt='priority_date'`. The Fristenrechner UI surfaces
   an optional "Prioritätstag" input (visible only when EP_GRANT is
   selected) that, when populated, anchors the publish-A1 calculation on
   the priority date instead of the filing. Falls back to filing date
   when the priority field is empty (the case for purely-EP applications
   with no foreign priority claim).

(4) Rule-code format normalisation
   Migration 029 normalises 'RoP 23' → 'RoP.023', 'RoP 29b' / 'RoP.029b'
   → 'RoP.029.b', 'RoP 220.1' → 'RoP.220.1', etc. across deadline_rules.
   Matches the canonical youpc format already used by the PR-1 imported
   event-deadline rule codes.

(+) AdjustForNonWorkingDays cap bumped 30 → 60
   Surfaced by the PR-2 smoke test: SoD on 2026-04-30 (3mo from trigger)
   landed on Sat 2026-08-29 instead of Mon 2026-08-31. The 30-iteration
   safety bound on AdjustForNonWorkingDays cannot walk past the 33-day
   UPC summer vacation plus flanking weekends. Bumped to 60. Pure-Go
   one-liner, locked by a follow-up production smoke (real
   paliad.holidays seed has the UPC vacation).

Schema (migration 029): two new nullable text columns on
paliad.deadline_rules — `condition_flag` and `anchor_alt`. Both ignored
by every existing rule; only the rows updated above carry values.

Models: DeadlineRule gains ConditionFlag + AnchorAlt (nilable strings).

Service: FristenrechnerService.Calculate now takes a CalcOptions struct
(PriorityDateStr, Flags). API handler accepts optional priorityDate and
flags fields on POST /api/tools/fristenrechner.

Frontend: TSX surfaces the priority-date row + CCR checkbox conditionally
on selectedType (only EP_GRANT / UPC_INF respectively). Client TS reads
them and threads through the API call. New i18n keys for both DE+EN.

Migration 029 dry-run validated on prod Supabase (BEGIN/ROLLBACK):
schema + UPDATEs apply cleanly, rule states match expected post-fix
shape. Tests + go build/vet + bun build all clean.
2026-04-30 11:11:47 +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
ce3227c1c0 refactor(t-paliad-080): service-layer naming sweep — Notiz/Termin/Frist/Projekt/Partei → Note/Appointment/Deadline/Project/Party
Mechanical rename across 8 service files plus their handler call sites and
two related helpers. The English types existed already; what changed are the
input-struct names, helper functions, list/create method suffixes, and
parameter names so they no longer mix English types with German parameter
names.

Renames cover:
- CreateNotizInput/UpdateNotizInput → CreateNoteInput/UpdateNoteInput,
  notizColumns/notizSelect → noteColumns/noteSelect, ListForProjekt/Frist/
  Termin → ListForProject/Deadline/Appointment, CreateForProjekt/Frist/
  Termin → CreateForProject/Deadline/Appointment, fristProjectID →
  deadlineProjectID
- CreateTerminInput/UpdateTerminInput → CreateAppointmentInput/
  UpdateAppointmentInput, terminColumns → appointmentColumns, ListForProjekt
  → ListForProject; parameter renames terminID → appointmentID, projektID
  → projectID
- CreateFristInput/UpdateFristInput → CreateDeadlineInput/
  UpdateDeadlineInput, fristColumns → deadlineColumns, ListForProjekt →
  ListForProject, isValidFristStatus → isValidDeadlineStatus; parameter
  renames fristID → deadlineID, projektID → projectID
- CreateProjektInput/UpdateProjektInput → CreateProjectInput/
  UpdateProjectInput, projektColumns → projectColumns,
  validateProjektStatus → validateProjectStatus, ProjektRole comment →
  ProjectRole
- CreateParteiInput → CreatePartyInput, parteiColumns → partyColumns,
  ListForProjekt → ListForProject; parameter renames parteiID → partyID
- OnTerminCreated/Updated/Deleted → OnAppointmentCreated/Updated/Deleted on
  the AppointmentCalDAVPusher interface and its CalDAVService impl
- formatTermin → formatAppointment in caldav_ical
- ListForProjekt → ListForProject, listWithProjekt → listWithProject,
  checklistInstanceWithProjektSelect → checklistInstanceWithProjectSelect,
  ClearProjekt → ClearProject (JSON tag clear_projekt unchanged — wire
  format)
- insertProjectEvent helper parameter projektID → projectID, error message
  "insert projekt_event" → "insert project_event"
- TeamService AddMember/RemoveMember/ListDirectMembers/ListEffectiveMembers
  parameter projektID → projectID; matching handler renames
- Frontend doc-comments referencing CreateProjektInput/UpdateProjektInput
  updated to CreateProjectInput/UpdateProjectInput

JSON wire tags (clear_projekt, etc.) and German user-facing strings
(glossary entries, search.go labels, email templates, changelog,
Terminsgebuehr, Fristenrechner product name) are intentionally untouched.

API contract unchanged. go build/vet/test ./... clean. Frontend bun build
clean.
2026-04-30 04:39:23 +02:00
m
31db66e3b7 refactor(t-paliad-076): consolidate visibility predicate — 6 dashboard/agenda sites use helper
F-2 from t-paliad-074 audit. The inlined visibility predicate had drifted
back into 6 hot-path SQL sites despite the central helper extracted in
t-paliad-058. Consolidating now so future visibility changes (e.g.
Chinese-wall in design v2 §8) only need one edit.

**Sites converted (6):**
- dashboard_service.go:158, 214, 244, 274
- agenda_service.go:138, 204

All six replace `$N = 'global_admin' OR EXISTS (path-walk)` with the
existing `visibilityPredicatePositional("p", 1)` helper. The helper
resolves global_admin via EXISTS on paliad.users — the role string no
longer flows through positional args, removing one foot-gun (typo'd
literal mismatched against bound role) entirely. Equivalence verified
on the live youpc DB:

    tester@hlc.de (global_admin, 1 team membership):
      old predicate count = 11   new predicate count = 11
    standard user (no team):
      old predicate count =  0   new predicate count =  0

**No new helper variant added.** The audit suggested
`visibilityPredicateLateral`, but the existing positional helper drops
into the dashboard/agenda WHERE clauses unchanged — adding a redundant
variant would be technical debt. dashboard/agenda do not use LATERAL
JOIN; they use plain WHERE EXISTS in (sub-)SELECT context, which is
already what visibilityPredicatePositional emits.

**Other 4 sites flagged by audit — left intentionally:**

- reminder_service.go:312, 325 are role-restricted (`pt.role = 'lead'`)
  membership checks, NOT visibility predicates. Adding a global_admin
  shortcut to the lead branch would over-include rows: every global
  admin would receive every project's lead-targeted reminder, even with
  the `own.escalation_contact_id` override that exists precisely to
  avoid that. global_admin already has its own dedicated branch in the
  query (`$3 = TRUE AND own.escalation_contact_id IS NULL` at line 328).

- deadline_service.go:422 (`assertCanAdminProject`) is role-restricted
  (`pt.role IN ('admin', 'lead')`) and already short-circuits global_admin
  at the Go level before the SQL runs (line 413). Both halves correct;
  no change needed.

- team_service.go:162 (`IsEffectiveMember`) was dead code with no callers
  in the entire repo. "Is this user a structural team member?" and
  "can this user see this project?" are different questions; adding a
  global_admin shortcut would have conflated them. Deleted instead.

**Test:** new TestVisibilityPredicate_DashboardAgendaForGlobalAdmin in
visibility_test.go seeds a project + deadline + appointment + activity
event with project_teams empty, then asserts a global_admin sees all
four on /dashboard and /agenda while a standard user sees none. Skips
when TEST_DATABASE_URL is unset (matching the existing live-DB tests).

**Pre-existing finding (separate concern):** the live-DB test gate is
currently blocked locally by a stale `public.paliad_schema_migrations`
(version=2, dirty=t) left over from before the schema-pinned tracker
landed. Authoritative `paliad.paliad_schema_migrations` is at version
27, dirty=f. Out of scope for this task; should be filed as cleanup.
2026-04-30 03:48:49 +02:00