9b8a865c5feed76c5ffb209862ffe5b9d97bffdc
37 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| a905911cf4 |
fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:
- internal/services/deadline_service.go:268 — DeadlineService.
ListVisibleForUser. Powers /api/events?type=deadline (dashboard
deadline rail, /deadlines page, every status bucket). Threw
`pq: column f.rule_id does not exist` on every request → 500
for any authenticated user hitting the dashboard.
- internal/services/projection_service.go:1250 — collectActualsForOverrides.
Same column on `paliad.deadlines d`. Logged once per projection
pass (`ERROR service: projection: deadlines: ...`); aliased the
rename to `rule_id` so the receiving struct tag still scans.
Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).
Root cause: mig 140 commit (
|
|||
| 1129baba7a |
feat(db,services): Slice B.4 destructive drop — paliad.deadline_rules retired, INSTEAD OF triggers on view route writes (mig 140, t-paliad-305 / m/paliad#93)
Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.
Pre-flip drift verified clean against prod:
deadline_rules=231 == sequencing_rules=231 == procedural_events=231
legal_sources=87
missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0
* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
Single TX, audit-first:
1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
(precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
2. Final reconciliation UPDATE on paliad.deadlines (no-op when
drift is already 0; defensive against last-minute writes).
3. DROP TRIGGER deadline_rules_audit_aiud.
4. Re-point FKs to sequencing_rules:
- paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
- paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
(the id values are identical — sr.id inherited dr.id at mig 136.)
5. DROP COLUMN paliad.deadlines.rule_id.
6. DROP TABLE paliad.deadline_rules.
7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
paliad.deadline_rules_unified. Triggers route writes into the
three new tables in the same TX, preserving the legacy column
shape on the wire so RuleEditorService SQL only needs a
table-name swap, not a structural rewrite. Synthetic-code mint
expression is byte-identical to mig 136 + the B.2 dual-write
helper. POST assertions confirm the table is gone, the column
is gone, and the snapshot matches.
Trigger design notes (1:N caveat documented in-trigger):
- PE identity columns (code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id) mirror from
the writing sequencing-rule.
- PE lifecycle columns (lifecycle_state, published_at, is_active)
deliberately do NOT mirror — a draft sequencing-rule cloned from
a published source shares the source's PE; we don't want the
clone's 'draft' lifecycle to leak back onto the source's PE.
Practical bound today (1:1 corpus); explicit comment in-trigger
for the eventual 1:N pattern.
* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
Best-effort restore from the snapshot. Triggers / indexes /
CHECK constraints from historical migrations are NOT replayed;
operator must reapply 078/079/091/095/098/122/128/134/135 to
bring the restored table to working shape. The down path is for
catastrophic recovery, not casual revert.
* internal/services/rule_editor_service.go —
Six syncDualWriteFromDeadlineRule(...) calls removed (the
INSTEAD OF triggers now do the fan-out). Five
INSERT/UPDATE paliad.deadline_rules statements (Create,
UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
flipLifecycle) renamed to paliad.deadline_rules_unified —
trigger handles the routing.
* internal/services/rule_editor_orphans.go — ResolveOrphan no
longer writes deadlines.rule_id (column dropped). Sets
sequencing_rule_id directly + derives procedural_event_id from
the matching sequencing_rules row in the same UPDATE statement.
* internal/services/deadline_service.go — deadlineColumns now
lists sequencing_rule_id (Deadline.RuleID still binds to it via
the db tag rename below). Update path's appendSet("rule_id",…)
flipped to appendSet("sequencing_rule_id",…) and post-write
derivation moved to the renamed syncDeadlineProceduralEventID
helper.
* internal/services/projection_service.go,
internal/services/submission_vars.go — `WHERE rule_id = $X`
reads on paliad.deadlines flipped to sequencing_rule_id.
* internal/models/models.go — Deadline.RuleID db tag changed from
"rule_id" to "sequencing_rule_id". Field name + JSON name kept
for backward compat with the frontend and existing Go callers;
semantic value is identical (same UUID).
* internal/services/dual_write.go — Massively trimmed.
Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
StartDualWriteDriftCheckLoop. All referenced
paliad.deadline_rules which no longer exists.
Kept (renamed): syncDeadlineProceduralEventID — derives
procedural_event_id from sequencing_rule_id after any
DeadlineService.Update that touched the back-link.
* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
bootstrap call (and its `time` import that only that call
needed). Comment notes the retirement.
* internal/services/dual_write_test.go — Removed the final
CheckDualWriteDrift assertion in
TestDualWrite_RuleEditorLifecycle (function deleted). The
per-step asserts against procedural_events / sequencing_rules
/ legal_sources cover the same contract by direct query.
Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
only).
Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
|
|||
| df592f9fc4 |
feat(db,services): Slice B.3 read cutover — flip reads to paliad.deadline_rules_unified view backed by sr+pe+ls (t-paliad-305 / m/paliad#93)
The new tables (mig 136) and the dual-write that keeps them in sync (B.2) have been steady-state in prod since mig 136 deployed at 13:24 UTC today. Drift verified clean before this commit: deadline_rules=231, sequencing_rules=231, procedural_events=231 (153 codes + 78 synthetic), legal_sources=87, zero mismatches across counts, FK integrity, lifecycle, is_active. This commit flips READ paths to source data from the new tables via a backwards-compatible view, leaving the dual-write WRITE paths untouched for B.4 to retire alongside the destructive drop. * internal/db/migrations/139_deadline_rules_unified_view.up.sql (new) — CREATE VIEW paliad.deadline_rules_unified projecting sr+pe+ls back into the legacy paliad.deadline_rules column shape. Same column names + types so the Go-side change is a 1-token substitution per query with no struct or scanner edits. Post-apply DO block asserts view row count = sequencing_rules row count (FK NOT NULL on procedural_event_id guarantees they match). * 10 service / handler files — every SELECT FROM paliad.deadline_rules (or JOIN paliad.deadline_rules) flipped to use the view: - internal/handlers/submissions.go (Schriftsätze list) - internal/services/deadline_rule_service.go (8 read sites) - internal/services/rule_editor_service.go (3 read sites — ListRules, getByID, validateSpawnNoCycle) - internal/services/rule_editor_orphans.go (candidate-rule lookup) - internal/services/submission_vars.go (loadPublishedRule) - internal/services/deadline_service.go (deadlines list join) - internal/services/fristenrechner.go (calculator reads) - internal/services/projection_service.go (projection reads) - internal/services/event_deadline_service.go (event→rule join) - internal/services/export_service.go (3 export sites — ref__deadline_rules) Verified semantically safe on live (read-only smoke): - 231 rows in view match 231 in legacy. - name + event_type pair: 231/231 match. - legal_source: 231/231 match (NULL on both sides treated as match). - submission_code: 153 non-NULL codes match exactly; the 78 synthetic 'null.<8hex>' codes diverge from legacy NULL but no reader filters on NULL submission_code (verified handlers/submissions.go: synthetic-code rules all have NULL event_type so the WHERE event_type = 'filing' filter excludes them; the Schriftsätze surface returns the same 105 rows). Scope decisions documented (deviation from design §5.3): - B.3 ships the READ flip only. WRITE paths (RuleEditorService Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle) retain the dual-write from B.2 — they write to both legacy and new tables. B.4 (destructive drop) will retire the legacy writes in the same slice that drops the table, avoiding a transient state where the legacy writes have no purpose. - The B.2 drift-check ticker (StartDualWriteDriftCheckLoop) stays active for the same reason: dual-write continues, so the invariants the loop checks remain meaningful. This shape is paliadin-approvable on a "good solution > strict phase boundary" reading of m's greenlight. If paliadin pushes back and wants the legacy writes removed in B.3, the refactor is ~300 LOC across the 5 RuleEditorService write methods + buildPatchSets split into PE/SR sets — schedulable as B.3.5 before B.4. Build + vet clean. TestMigrations_NoDuplicateSlot passes. |
|||
| 38ebccc907 |
feat(services): Slice B.2 dual-write — RuleEditorService writes deadline_rules AND procedural_events / sequencing_rules / legal_sources (t-paliad-305 / m/paliad#93)
Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.
* internal/services/dual_write.go (new) —
- syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
legal_sources + procedural_events + sequencing_rules from the
just-written deadline_rules row. Pure SQL projection, no Go-side
struct mapping. Synthetic-code mint expression is byte-identical
to mig 136 ('null.' || first 8 hex of stripped uuid).
- syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
legacy rule_id back-link onto deadlines.procedural_event_id +
sequencing_rule_id. Handles NULL rule_id naturally (collapses both
new columns to NULL).
- CheckDualWriteDrift(ctx, conn): nine read-only count queries +
integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
log routing.
- StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
that runs CheckDualWriteDrift every `interval` (default 6h) for
the lifetime of ctx. Clean run logs at INFO; drift at WARN with
full report.
* internal/services/rule_editor_service.go —
- Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
published draft AND the cloned-from peer it just archived as a
cascade. The audit_reason already set via setAuditReasonTx
propagates to the new-table writes (same TX, same session).
* internal/services/rule_editor_orphans.go —
- ResolveOrphan calls syncDeadlineDualLinks after UPDATE
paliad.deadlines SET rule_id = $1, so the parallel new columns
follow the legacy back-link.
* internal/services/deadline_service.go —
- DeadlineService.Update calls syncDeadlineDualLinks when
input.RuleSet is true (auto/custom rule swap from t-paliad-258).
* cmd/server/main.go —
- Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
reminder scanner. Inherits bgCtx so the goroutine stops on
SIGTERM. Interval 6h.
* internal/services/dual_write_test.go (new) —
- TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
→ Archive, asserts the new tables mirror at each step. Final
CheckDualWriteDrift returns zero drift.
- TestDualWrite_SyntheticCodeForNullSubmission: rule created with
submission_code=NULL gets a 'null.<8hex>' procedural_events row
matching mig 136's mint expression byte-for-byte.
Scope decisions documented in the commit:
- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
back to legacy" reads as "reads stay on legacy as the safety net
while drift-check validates the new tables". B.3 swaps reads to
new tables only AND stops writing to deadline_rules — that's a
separate slice per the design's §5.2/§5.3 split.
- B.2 does NOT modify submission_drafts, projection_service, the
Fristenrechner calculator, the SubmissionVarsService, the
Schriftsätze list query, or any other reader. They keep reading
deadline_rules unchanged. The new tables are populated in parallel
for B.3's cutover.
- Audit triggers on deadline_rules continue to fire as before. The
new tables have no audit triggers yet (a later slice can add
parallel audit rows once the new tables are authoritative).
- Drift-check uses default 6h interval — short enough that a broken
dual-write surfaces within the same business day, long enough that
the count-COUNTs don't churn the pool. Override via the caller in
cmd/server.
Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
(existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
call twice, safe to re-run after a partial failure.
Build + vet clean. TestMigrations_NoDuplicateSlot passes.
|
|||
| 045accc6d9 |
mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.
Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
rule renders read-only as 'Auto | <Name · Citation>' next to the
field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
nullable column, migration 122). Mutually exclusive with rule_id at
the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
the current Type — no stale state.
Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
Existing rows: empty custom_rule_text + non-null rule_id = Auto-
equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
RuleSet=true is the discriminator so absent fields don't overwrite
the row (PATCH semantics). RuleID and CustomRuleText are mutually
exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
surfaces can render it.
Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
dropdown, the override-warning slot, and the collapsed-by-Regel Typ
view. Strip the (Rule→Type) auto-fill machinery — direction is now
one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
rule by project's proceeding, then jurisdiction match, then first
candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
Custom text → proceeding → fallback) so the recipe still produces a
sensible title even when Custom is used.
Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
enterEdit initialises the mode from the persisted deadline; Save
PATCHes with rule_set:true + the chosen rule pointer.
Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
Standardtitel, events.ts ruleDisplay (REGEL column on /events),
projects-detail.ts Fristen table, views/shape-list.ts generic
rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
name + citation chip separately and matches the canonical pattern;
no change needed. Schriftsätze table is column-shaped (name + code
in distinct columns) and out of scope per the addendum.
CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
family (retired with the catalog dropdown).
i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
auto_pick_type, custom_badge, custom_placeholder,
mode.toggle_to_auto, mode.toggle_to_custom).
Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).
Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).
Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
|
|||
| becf4f0ce3 |
hotfix(deadlines): use date(completed_at) not ::date — sqlx named-param bug
Production down (Termin/Fristen list returning 503) since 13:28 UTC on
my own
|
|||
| 007ebc2794 |
fix(deadlines): "Heute" filter retains deadlines completed today
m's 2026-05-22 observation: checking off a deadline on the Heute view made it disappear immediately — no sense of progress, no record that the day's work is getting done. Root cause: filterDeadlines was filtering on `status = 'pending'` even for the Heute bucket. The bucket should be a date-scoped view of the day's deadlines, not a pending-only queue. Fix: include items where `due_date = today` AND either still pending OR completed today (`completed_at::date = :today`). Items completed on earlier dates still drop out — the bucket stays "today's work" rather than "everything that was ever due today". Frontend already renders completed deadlines as strikethrough/green via `frist-urgency-done` (see frontend/src/client/events.ts:254), so no client change needed. Dashboard counter (`SELECT … COUNT(*) FILTER WHERE status='pending'`) intentionally unchanged — keeps the lime card a "remaining to do" indicator, like an unread-mail badge. |
|||
|
|
4ecea7a4bb |
feat(paliadin/agent-glyph): t-paliad-161 Slice E — ✨ alongside 👀
When a pending row was drafted by Paliadin (requester_kind='agent' on its in-flight approval_request), surface a sparkle ✨ next to the existing eye-pill 👀. The two glyphs are orthogonal: 👀 = "needs approval", ✨ = "Paliadin drafted this". Either can change without the other, so the visual taxonomy stays decomposable for any future autopilot mode where 👀 disappears but ✨ stays. Read-path: - DeadlineService.ListVisibleForUser + AppointmentService.ListVisibleForUser LEFT JOIN paliad.approval_requests on pending_request_id and project ar.requester_kind into the row. NULL when no request is pending. - models.DeadlineWithProject + AppointmentWithProject grow RequesterKind *string. The list-projection helpers (projectDeadline / projectAppointment in event_service.go) carry it into EventListItem. - /api/events response now includes requester_kind on every pending row; /api/inbox already does (Slice D extended approvalRequestViewColumns). Render-path: - frontend/src/client/events.ts — new AGENT_PILL_GLYPH constant ("✨"), agentPill rendered into the title cell next to the existing pendingPill when item.approval_status='pending' AND item.requester_kind='agent'. EventListItem TS shape gains `requester_kind?: "user" | "agent"`. - frontend/src/client/agenda.ts — same pattern, agendaItem TS shape + agentPill rendered next to pendingPill in the headline span. - frontend/src/client/inbox.ts — ApprovalRequestView gains requester_kind + agent_turn_id; the meta line replaces the requester's plain name with "Anna ✨ Paliadin" when the request was drafted by the agent. CSS: new .approval-pill--agent modifier in global.css using only existing tokens (--color-bg-lime-tint / --color-surface-2 / --color-text), mirroring the .approval-pill--icon shape so the two glyphs sit side-by-side at the same baseline. i18n: 3 new keys × 2 langs (approvals.agent.label / approvals.agent.byline / approvals.agent.suggestion_pending) — total 1966 → 1969. Build clean (frontend + go), tests green. Refs: docs/design-paliadin-inline-2026-05-08.md §8. |
||
|
|
a3052eb085 |
feat(paliadin/suggest): t-paliad-161 Slice D — agent-suggested write path
Paliadin can now draft deadlines + appointments through two new owner-gated HTTP endpoints. Drafted entities land in the existing approval pipeline as approval_status='pending' with requester_kind='agent' + agent_turn_id linking back to the chat turn that produced the suggestion. The user reviews via the same eye-pill 👀 surface (with ✨ added in Slice E). POST /api/paliadin/suggest/deadline POST /api/paliadin/suggest/appointment Wiring: - ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate; always creates an approval_request (bypassing policy lookup) and stamps requester_kind='agent' + agent_turn_id. Required-role defaults to 'associate' so the deadlock check has a non-NULL threshold; m's lock-in for Q11 (every agent suggestion needs the user's eye) means bypassing the policy gate is correct here, not a regression. - The shared `submit` kernel takes an optional agent_turn_id pointer. All four lifecycle entry points (SubmitCreate / SubmitUpdate / SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes the turn id. INSERT to approval_requests now writes both requester_kind + agent_turn_id atomically (xor-check on the schema enforces consistency). - models.ApprovalRequest grows the two columns + their JSON tags so the inbox view + Verlauf renderer can read provenance without an extra fetch. - approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id to the SQL projection; both surfaces (ListPendingForApprover, ListSubmittedByUser, GetRequest) inherit the new fields free. - CreateDeadlineInput + CreateAppointmentInput each get an optional AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through SubmitAgentCreate instead of the regular SubmitCreate. Default-zero behaviour is unchanged for every existing caller. - handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via requirePaliadinOwner (same gate /paliadin uses), JSON-bodied, RFC3339 + ISO-date validation, 409 + a useful message on ErrNoQualifiedApprover. - Project-event audit metadata gains requester_kind + agent_turn_id so the project's Verlauf can render "Paliadin hat eine Frist vorgeschlagen ✨" without joining approval_requests (Slice E reads this). SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested writes" section with the tool catalog, behaviour rules ("never write directly", confirmation in the response file, project_id lookup discipline, RFC3339 dates, no chained tool calls per turn), and the 409 error contract. go build + go vet + go test all clean. No frontend changes in this slice — Slice E lights up the ✨ on existing eye-pill surfaces. Refs: docs/design-paliadin-inline-2026-05-08.md §7. |
||
|
|
3a41aa9209 |
feat(approvals/t-paliad-160 slice1+2): split policy + 409 handler
m's locked redesign (2026-05-08 16:40): replace `required_role` (with
'none' sentinel) with two columns — `requires_approval boolean` (the
gate) + `min_role text` (the seniority threshold). Cleanly separates
"approval applies at all" from "who's allowed to approve".
M1 phase: additive migration 064 adds the columns, backfills from the
legacy required_role ('none' → false/NULL; else → true/role), and
rewrites paliad.approval_policy_effective() to most-strict-wins:
- requires_approval := bool_or across project + ancestor + unit_default
- min_role := MAX(approval_role_level) among requires_approval=true
The legacy required_role column survives this slice as a dual-read
mirror (resolver returns it too) so any caller that hasn't cut over
keeps working. M2 will drop required_role.
Service layer (approval_service.go): LookupPolicy + GetEffectivePolicyOne
read the new columns; UpsertProjectPolicySplit / UpsertUnitPolicySplit
accept the new shape directly; legacy UpsertProjectPolicy /
UpsertUnitPolicy stay as thin shims that map required_role through
splitFromLegacy(). ApplyMatrixToDescendants writes both columns.
Handler 409 mapping (§B): writeServiceError now consults a shared
mapApprovalError() helper before falling through to the generic 500.
ErrConcurrentPending → HTTP 409 with body
{code: "awaiting_approval", message, request_id?, required_role?}.
PendingApprovalError wraps ErrConcurrentPending with the in-flight
request id + role so the UI knows which request to point a withdraw
button at. ErrNoQualifiedApprover, ErrSelfApproval, ErrNotApprover,
ErrRequestNotPending all mapped consistently. writeApprovalError
now defers to the same helper for shape consistency.
Models: ApprovalPolicy + EffectivePolicy gain RequiresApproval/MinRole
fields. RequiredRole stays as a dual-read mirror until M2.
Tests: TestMapApprovalError_* covers the four 409/403 branches and the
"no match — fall through" case. Existing approval service tests pass
unchanged.
Defers per task spec to follow-up slices:
- A3 (admin UI 2-control flip)
- C+E (badge + withdraw button on detail pages)
- D (/inbox Meine Anfragen visibility fix)
- M2 (drop required_role column)
|
||
|
|
db4279d148 |
fix(t-paliad-152): /api/events honours direct_only — Fristen/Termine subtree toggle works again
The frontend toggle on /projects/{id} Fristen + Termine emitted
`&direct_only=true`, but `handleListEvents` and `handleEventsSummary`
never read the param, so EventListFilter / EventSummaryFilter went out
without DirectOnly and the backend always returned the subtree-aggregated
default (per t-paliad-139). The toggle has been silently dead since the
Fristen/Termine surfaces migrated to /api/events in t-paliad-139.
Backend-only fix, symmetric across endpoints:
- ListFilter (deadlines), AppointmentListFilter, EventListFilter,
EventSummaryFilter all gain DirectOnly bool.
- When ProjectID != nil && DirectOnly, the SQL predicate swaps from
projectDescendantPredicate("p") to a direct `<alias>.project_id = :project_id`
scope on each rail (deadline list, appointment list, deadline+appointment
bucket counts).
- Handlers parse `direct_only` via the existing parseDirectOnly helper.
- Test extends project_filter_descendants_test.go with three DirectOnly=true
assertions (events, deadlines, appointments) — each must collapse to the
one direct seed row.
DirectOnly is a no-op when ProjectID is nil or PersonalOnly is set —
PersonalOnly already nullifies ProjectID.
Verlauf is untouched: it still uses /api/projects/{id}/events, which
already wired direct_only via projects.go:512.
|
||
|
|
9184e9b0ef |
feat(t-paliad-148) commit 4/6: reminder + deadline + derivation cleanup — pt.role → pt.responsibility
reminder_service.go: BuildDigest audience predicate switches the
"project lead anywhere on the path" branch from `pt.role = 'lead'` to
`pt.responsibility = 'lead'`. Two SQL sites + comment updated.
deadline_service.go: assertCanAdminProject (Reopen permission) switches
from `pt.role IN ('admin','lead')` to `pt.responsibility = 'lead'`.
The legacy 'admin' was already dead since t-paliad-051 — never present
in project_teams.role to begin with — so this also drops a slow leak.
Doc comments + error message updated.
derivation_service.go: ListDescendantStaffed SELECTs both `pt.role` and
`pt.responsibility`, returns the new column to the team-tab "from
descendants" subsection (so the firm-tier badge + responsibility pill
both render). ORDER BY switches to responsibility.
Build + vet clean. Pure-Go tests pass.
|
||
|
|
2d06cdf20e | Merge: t-paliad-139 Phase 1 — /projects/{id} aggregation bug fix (use projectDescendantPredicate on 3 legacy narrow methods + frontend toggle + attribution chip) | ||
|
|
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.
|
||
|
|
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.
|
||
|
|
93c4453ce5 | Merge remote-tracking branch 'origin/main' into mai/cronus/inventor-dual-control | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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).
|
||
|
|
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). |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
f583c650a2 |
fix(t-paliad-067): PR-1 i18n leak sweep + activity narrative (F-04, F-07, F-10, F-12, F-21, F-29, F-35, F-46)
Per docs/audit-polish-2-2026-04-29.md PR-1. Single concern: text rendered
to a German narrative that was still English or raw-keyed.
- F-04 deadlines-new.ts now references the existing fristen.field.akte.*
keys (the SSR template already used them) instead of the non-existent
fristen.field.project.* keys, so the picker no longer renders the raw
i18n key.
- F-07 + F-21 dashboard activity log + project Verlauf:
• i18n.ts gains the missing dashboard.action.short.project_type_changed
plus a parallel event.title.* key set (full noun-phrase form for
Verlauf, complementing the dashboard's verb form) and
event.description.* templates with {title}/{count}/{parent}
placeholders.
• New translateEvent(eventType, title, description) helper localizes a
stored project_events row for display; parses both new value-only
descriptions and legacy English+DE-mix shapes ("Deadline „Foo"
geändert", "Type case → litigation", "Note zu deadline hinzugefügt").
Wired into dashboard.ts and projects-detail.ts renderers.
• Go services now write descriptions as value-only payloads (the title,
the count, the parent slug, or "old → new") so future rows are
locale-clean. Affected services: deadline_service.go (5 sites),
appointment_service.go (3 sites), note_service.go (1 site),
project_service.go (2 sites: status_changed, project_type_changed).
• Translation covers historical project_events rows too — the
legacy-format parsers in translateEventDescription strip the English
"Type"/"Status" prefix and pull the quoted title out of "Deadline
„Foo" geändert" so DE/EN renders correctly without DB migration.
• Renamed dashboard.action.short.project_* DE labels from "...Akte" to
"...Projekt" to match the project-rename direction.
- F-10 deadlines list REGEL column now resolves rule_name/rule_name_en
via a JOIN-side alias on deadline_service.ListWithProjects (added
RuleName/RuleNameEN to DeadlineWithProject). New ruleDisplay() helper
prefers the localized rule name and falls back to em-dash; never
renders the raw rule_code slug ("inf.rejoin").
- F-12 fristen.col.akte and termine.col.akte DE values flip "Akte" →
"Projekt"; matching SSR placeholder text on deadlines.tsx and
appointments.tsx column headers (EN already said "Matter").
- F-29 the checklists empty-state hint on /projects/{id}/checklists is
split into prefix/link/suffix spans so the <a href="/checklists"> stays
intact after applyTranslations() runs (the previous single-string i18n
value collapsed the anchor on first paint).
- F-35 projekte.subtitle DE flips "Fälle" → "Verfahren" (matches the
actual type taxonomy: Mandant/Streitsache/Patent/Verfahren/Projekt).
Same fix on projekte.empty.hint. EN keeps "cases" since EN labels the
case type as "case".
- F-46 dashboard.greeting.prefix EN flips "Good day" → "Hello".
Verified
- go build ./... + go vet ./... + go test ./... all green.
- bun run build clean.
- Dashboard activity widget + project Verlauf renderer verified by
reading the translated paths; live smoke pending deploy.
|
||
|
|
abd99980fc |
fix(t-paliad-058): honor global_admin in visibilityPredicate
Mirror paliad.can_see_project's global-admin shortcut at the application
layer. The in-Go predicate previously relied on callers passing
user.GlobalRole as a separate :role / $roleArg parameter — the positional
variant compared against the literal 'admin' instead of 'global_admin',
so any global_admin without team membership got 404 from
/api/projects/{id} (and the other positional callsites: ListAncestors,
BuildTree, GetTree, deadline counts).
Fold the gate into a Go helper that resolves global_admin via EXISTS on
paliad.users, keyed only by userID. Callers no longer pass role, which
removes the foot-gun entirely. Drops the unused
visibilityPredicatePlaceholder dead helper.
Adds a regression test (visibility_test.go) covering global_admin +
standard user against GetByID and BuildTree without project_teams rows.
|
||
|
|
b34500ad31 |
feat(t-paliad-051): split paliad.users.role into job_title + global_role
Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.
Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:
* paliad.users.job_title — free text, NULL allowed, display only.
NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
default 'standard'. The only thing that gates ops.
Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
with the historic u.role IN ('partner','admin') gates simplified
to u.global_role='global_admin' only (job_title NEVER gates)
Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
global_role with last-admin demotion guard (ErrLastGlobalAdmin);
IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
department/party/note/checklist_instance): pass user.GlobalRole into
visibility predicates; partner-or-admin gates simplified to
global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
-> JobTitle (*string)
Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
'Standard'|'Global Admin' badge + dropdown editor; last-admin
demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
gates dropped, only global_admin grants those operations
Tests:
* user_service_test: bootstrap promotes first user to global_admin,
subsequent default to standard; AdminUpdateUser refuses to demote
the last global_admin; IsAdmin reads global_role
Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
|
||
|
|
3aa8bae8e9 |
feat(deadlines): add reversible deadline status — admin/lead reopen (t-paliad-045)
Completed deadlines were irreversible — accidental completions could not be
undone. Adds a symmetric reopen path for global admins and project leads.
Server:
- PATCH /api/deadlines/{id}/reopen flips status back to pending and clears
completed_at, audit-logged as project_event kind 'deadline_reopened'.
- DeadlineService.Reopen mirrors Complete shape; new
assertCanAdminProject helper gates on global users.role='admin' OR
paliad.project_teams.role IN ('admin','lead') walking the project path.
- Service test (skipped without TEST_DATABASE_URL) covers admin + non-admin
paths and idempotent no-op.
UI:
- /deadlines/{id} detail: Wieder öffnen / Reopen button replaces the
disabled completed-state Erledigt button (admin/partner only).
- /deadlines list: per-row ↻ icon for completed rows (admin/partner only;
project-lead-only users use the detail page).
- i18n: fristen.detail.reopen, fristen.action.reopen,
dashboard.action.deadline_reopened (DE + EN).
|
||
|
|
3f0c26fd3a |
feat(frontend): PWA mobile BottomNav + Quick-Add sheet (t-paliad-041)
Phone-first bottom navigation per pwa-baseline.md. Renders only at <768px; tablets and desktop are unchanged. Slots: Start / Projekte / [+] Anlegen / Agenda / Menü. - Center [+] opens a slide-up <dialog> sheet with three rows: Frist, Termin, Projekt. Native showModal() + ::backdrop, ESC and backdrop-tap dismiss, transform-based slide-up transition. - Right Menü slot reuses the existing Sidebar mobile drawer via a new exported toggleMobileSidebar() (DRY with the legacy hamburger handler). - Agenda slot carries a red-dot badge: count = today + overdue pending deadlines (live via /api/deadlines/summary, refreshed every 60s). Pulse animation when overdue > 0 — m: "Due is the latest we can do, OVERDUE is a catastrophy." - visualViewport resize watcher hides the bar when the on-screen keyboard opens (>100px height shrink) so it doesn't cover form fields. - safe-area-inset-bottom padding on the bar; main padding-bottom adjusts on phones so the last row stays above the bar. PWA shell groundwork (defers manifest/SW/install-prompt to follow-ups): - viewport-fit=cover on every page (required for safe-area to register) - theme-color #65a30d (lime), apple-mobile-web-app-capable, status-bar style — all 30 page heads updated in one sweep. Backend: deadline_service.SummaryCounts gains a `today` bucket so the Agenda badge can distinguish "due today" from "this week" without a new endpoint. Files added: frontend/src/components/BottomNav.tsx frontend/src/client/bottom-nav.ts Verified visually via headless chromium at 375x812, 800x600, 1280x800: phone shows BottomNav (5 slots, lime [+] elevated), tablet shows the existing hamburger only, desktop sidebar untouched. go build/vet/test and bun run build all clean. |
||
|
|
70c3f08668 |
fix(projects-detail, services): empty-list endpoints returned JSON null → tab content blank
m reported /projects/{id} loaded the chrome and tabs but every panel was
empty even with deadlines/appointments/team rows that should render.
Console error: "Cannot read properties of null (reading 'length')" at
projects-detail.js — the Project Detail page expects every list endpoint
to return [] but at least two were returning literal JSON null.
Reproduced via the in-page fetch console:
/api/projects/{id}/parties → 200, body: "null"
/api/projects/{id}/children → 200, body: "null"
/api/projects/{id}/deadlines → 200, body: "[…]" (had data, fine)
/api/projects/{id}/team → 200, body: "[…]" (had data, fine)
Root cause: every list service in internal/services declared its result
as `var rows []models.X` and returned that to the handler, which
encoding/json marshals as `null` when the SELECT returns zero rows
(nil slice, not empty slice). Most endpoints happen to have data so
the bug stayed dormant until t-paliad-038 hit /projects/{id} where
parties + children are commonly empty.
Fix at the source — every list service that JSON-marshals to a client
now initialises `rows := []models.X{}` so the encoder produces `[]`:
party_service ListForProjekt
project_service List, ListAncestors, BuildTree, GetTree
(ListChildren goes through List)
deadline_service List + ListForProjekt
appointment_service List + ListForProjekt
note_service ListForProjekt
checklist_instance_service ListForProjekt
team_service List
department_service List + ListMembers + ListWithMembers
caldav_service was deliberately left alone — its lists are admin-only
debug surfaces, not user-facing tab fillers, and changing them would
mix scopes.
Belt-and-braces on the client too — projects-detail.ts now coerces every
`await resp.json()` for an array endpoint with `?? []` so a future
service regression can't crash the page.
Verified: go build/vet/test clean, bun run build clean.
|
||
|
|
3111c7440a |
fix(polish): i18n leaks, untranslated labels, /api/departments 500, 404 chrome (t-paliad-037)
Four bugs from tests/smoke-auth-2026-04-25.md.
Bug 4 — Dashboard activity log leaked raw i18n keys. Root cause was a mix
of three issues:
- Go services wrote German event_types (frist_created, termin_*,
projekt_*, notiz_created, checkliste_*) — no matching i18n key.
- i18n.ts only had keys for legacy `akte_*` types, none for what was
actually being written.
- The dashboard renderer always rendered `e.title` (a static label like
"Project angelegt") as a trailing detail, duplicating the action verb.
Old `akte_created` rows had English titles ("Akte created") that
bled into German output.
Switched all event_type writes to English (deadline_*, appointment_*,
project_*, note_created, checklist_*, deadlines_imported). Moved dynamic
text out of `title` into `description` for status_changed and
deadlines_imported so the static label/description split is consistent.
Added i18n keys for both new English types AND legacy German types so
historical project_events rows render cleanly. Dashboard now prefers
description over title; falls back to title only for events with no
i18n match (defensive for any unknown legacy kinds).
Bug 5 — /deadlines and /appointments matter-filter dropdowns showed raw
keys `fristen.filter.project.all` / `termine.filter.project.all`. The
client TS referenced English-prefix keys that didn't exist; the existing
keys use `fristen.filter.akte.*` / `termine.filter.akte.*`. Updated the
client refs to match the existing keys (kept i18n key namespace stable
to avoid touching every other reference).
Bug 6 — /api/departments?include=members returned 500. Reproduced via
curl: ListWithMembers (and ListMembers) used `LEFT JOIN paliad.users` on
a member.user_id that FKs auth.users — pre-onboarding members produced
NULL u.email/display_name/office/role, which sqlx can't scan into the
non-pointer string fields. Switched both to INNER JOIN; unonboarded
members are skipped (correct UX — without a profile there's nothing to
render anyway).
Bug 9 — Bare `404 page not found` on unknown auth-gated paths
(/whatsnew, /search, /settings/notifications, etc). Added a chromed 404
page (frontend/src/notfound.tsx) with sidebar + friendly card + "back
to dashboard" CTA, plus a catch-all handler on the protected mux that
serves it with HTTP 404 (and JSON 404 for /api/* misses). Anonymous
visitors keep being redirected to /login by the auth middleware before
the catch-all runs, so no separate marketing-shell variant needed.
Verification:
- go build ./... + go vet ./... + go test ./... clean
- bun run build clean (notfound.html + notfound.js produced)
- Visual checks pending after deploy
|
||
|
|
4bc23958ee |
fix(services): add db tags to SummaryCounts so sqlx maps this_week (et al.)
Bug 1 (smoke-auth-2026-04-25.md) had a third symptom beyond the RLS function bodies and the visibilityPredicate `::uuid[]` issue: /api/deadlines/summary and /api/appointments/summary returned 500 with `sqlx: missing destination name this_week in *services.SummaryCounts`. Cause: SummaryCounts (deadline) and AppointmentSummaryCounts had only `json:` tags. sqlx falls back to the lower-cased field name when no `db:` tag is present, so `ThisWeek` mapped to `thisweek` — but the SQL aliases the column as `AS this_week`. Adding `db:"this_week"` (and matching tags for the other fields) lets sqlx find the destination. Verified by hitting both endpoints; previously 500 → now expected 200. |
||
|
|
49c6bc75ca |
refactor(rename): handler functions, routes, legacy 301 redirects
Second rename pass closing the backend cleanup:
* handler functions (handleListProjekte, handleCreateFrist, …) renamed
to English equivalents so every symbol in the handler package matches
the URL/entity it serves.
* services.FristStatusFilter + filter constants renamed to
DeadlineStatusFilter / DeadlineFilterOverdue etc.
* services.TerminListFilter / TerminCalDAVPusher / TerminSummaryCounts
renamed to AppointmentListFilter / AppointmentCalDAVPusher /
AppointmentSummaryCounts.
* GlossarTerm/GlossarSuggestion/glossarTerms → Glossary*.
* CourtsFeedback/CourtsResponse (formerly Gerichte*).
* handlers.Services.{Projekt,Parteien,Frist,Termin,Notiz,Dezernat} →
{Project,Party,Deadline,Appointment,Note,Department}; dbServices
struct + consumers likewise.
* email templates: {{.FristURL}} → {{.DeadlineURL}}, {{.FristenURL}} →
{{.DeadlinesURL}}.
* links.go category IDs: gerichte → courts.
* cmd/server/main.go local vars: projektSvc/terminSvc/dezernatSvc →
projectSvc/appointmentSvc/departmentSvc.
Routes:
* removed all /api/akten alias routes (API clients use /api/projects now).
* removed /api/akten/*/deadlines, /*/notes, /*/parties, /*/appointments,
/*/checklists, /*/events, /*/summary alias variants.
* new internal/handlers/redirects.go registers 301 Moved Permanently
redirects for every legacy German GET path: /akten, /projekte, /fristen,
/termine, /notizen, /einstellungen, /checklisten, /dezernate, /parteien,
/gerichte, /glossar. Sub-paths + query strings are preserved so old
bookmarks keep working.
Kept in German (product names, per task spec):
* /tools/fristenrechner, /tools/kostenrechner, /tools/gebuehrentabellen
* FristenrechnerService / KostenrechnerService types
* User.Dezernat + paliad.users.dezernat free-text legacy column (separate
from the new paliad.departments entity).
go build / vet / test clean.
|
||
|
|
3faec6c526 |
refactor(rename): German→English for backend (tables, types, services, handler files)
t-paliad-025 — Phase 1: backend rename.
Migrations 018+019 rewritten from scratch with English table/column
names throughout. Since v2 schema (018/019) has never been applied to
youpc prod DB, this is a clean replacement — not an ALTER RENAME chain.
Pre-existing German tables (parteien, fristen, termine, dokumente,
akten_events, notizen) are renamed inline in 018 via ALTER TABLE … RENAME
TO alongside the akte_id → project_id column rewrite.
Renames applied:
projekte → projects
projekt_teams → project_teams
projekt_events → project_events (via akten_events → project_events)
fristen → deadlines
termine → appointments
parteien → parties
notizen → notes
dezernate → departments
dezernat_mitglieder → department_members
dokumente → documents
can_see_projekt → can_see_project
notiz_is_visible → note_is_visible
akte_id / frist_id / termin_id / akten_event_id → project_id /
deadline_id / appointment_id / project_event_id
termin_type → appointment_type
Go types + services renamed:
Projekt / ProjektService / ProjektEvent / ProjektTeamMember
Frist / FristService / FristWithProjekt
Termin / TerminService / TerminWithProjekt / TerminType
Notiz / NotizService / ChecklistInstanceWithProjekt
Dezernat / DezernatService / DezernatMitglied
Partei / Parteien / ParteienService
Files renamed (git mv):
internal/services/{projekt,frist,termin,notiz,dezernat,parteien}_service.go
→ {project,deadline,appointment,note,department,party}_service.go
internal/handlers/{projekte,fristen,fristen_pages,termine,termine_pages,
notizen,dezernate,akten_pages,gerichte,glossar,checklisten}.go
→ {projects,deadlines,deadlines_pages,appointments,appointments_pages,
notes,departments,projects_pages,courts,glossary,checklists}.go
internal/checklisten/ → internal/checklists/
internal/db/migrations/018_projekte_v2.* → 018_projects_v2.*
internal/db/migrations/019_seed_dezernate_from_user_text.*
→ 019_seed_departments_from_user_text.*
User-facing i18n strings (DE/EN labels) stay untouched. Product names
Fristenrechner / Kostenrechner / Gebührentabellen stay German.
Build + vet + tests clean.
|