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)
Two migrations both named 032 collided when t-111 and t-112 merged in
parallel — 032_deadline_notes_en (t-112, already applied to the DB and
tracker bumped to v33) vs. 032_deadlines_rule_code (t-111). The Go
migration runner refuses to init the driver when two files share a
prefix, so paliad.de was 404 across all routes (container in restart
loop with `migration failed: ... duplicate migration file:
032_deadlines_rule_code.down.sql`).
Renumbering t-111's pair to 034 (033 was used by t-112's
trigger_events_de backfill).
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
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).
Per audit recommendations §6.2 (rec 5-9). Ports 14 RoP rules from
youpc's deadline calc into paliad.deadline_rules so they're surfaceable
in the timeline (course-of-proceedings) Fristenrechner mode in addition
to the existing trigger-event mode.
Adds 4 new proceeding types and extends 2 existing trees:
- UPC_DAMAGES (new) — Schadensbemessung (R.137.2, R.139 reply, R.139 rejoin)
- UPC_DISCOVERY (new) — Bucheinsicht (R.142.2, R.142.3 reply, R.142.3 rejoin)
- UPC_COST_APPEAL (new) — Berufung Kostenentscheidung (R.221.1)
- UPC_APP_ORDERS (new) — 15-day order-flavor (R.220.2, R.220.3, R.237 b, R.238.2)
- UPC_INF extended — R.151 chained off inf.decision
- UPC_APP extended — decision-flavor cross-appeal pair (R.237 a, R.238.1)
Total: 14 RoP rules across 5 families. New types appear in the
proceeding-type picker via deadlines.upc_damages / upc_discovery /
upc_cost_appeal / upc_app_orders i18n keys (DE + EN).
Design notes in the migration explain why some rules live in their own
proceeding type (when the legal anchor differs from UPC_INF/UPC_APP's
trigger date) vs being chained off existing rules.
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.
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.
Bundle of small audit findings, all doc-only or dead-code:
- F-5: refresh stale escalation-contact comment in models.User —
Settings UI dropdown shipped 2026-04-29 (t-paliad-066).
- F-10: add "OBSOLETED by migration 018" note to migrations 004/005/006
so readers stop hunting for the live shape in obsolete files.
- F-11: document the data-loss semantics of dropping
paliad.partner_unit_events on the 027 down — audit rows are
append-only telemetry, accepted loss on rollback.
- F-15: drop the patholo_session / patholo_refresh cookie fallback
added during the 2026-04-16 rebrand. Active users have long since
been re-authed through the upgrade path; inactive users hit the
normal /login flow.
- F-16: refresh stale /api/departments comment in team_pages.go to
/api/partner-units (renamed in t-paliad-070).
- F-17: move internal/db/migrations/_dev/mock_supabase_auth.sql to
internal/db/devtools/ so a future loosening of the //go:embed
pattern can't accidentally ship the dev-only fixture.
- F-18: update docs/project-status.md "Audit polish-2" entry — the
batch shipped via t-paliad-067 / 068 / 073, follow-ups are now
tracked under the 2026-04-30 re-audit + t-paliad-074.
go build / vet / test clean.
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.
Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
language file when no DB row, Save validates parse + structural
invariants and writes a version, Reset deletes the active row, Restore
copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
back to the embedded default if the active row is malformed at parse
time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
text/template strings stored in the (key, lang) row. Default subjects
ship with a {{/* keep this phrasing */}} comment pointing at the
reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
+ .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
RequireAdminFunc(users) admin middleware, same shape as /admin/team.
Frontend:
- /admin/email-templates list page — three cards (one per template),
each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
textarea + variable docs + actions on the left, sandboxed iframe
preview + version log on the right. 500 ms debounced live preview;
save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.
Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.
Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean
Design: docs/design-email-templates-2026-04-29.md.
Backend rename (frontend lands in next commit):
- Migration 026: rename paliad.departments → paliad.partner_units,
paliad.department_members → paliad.partner_unit_members, junction FK
department_id → partner_unit_id, plus all constraints/indexes/policies.
Pre-drop seed re-runs migration 019's logic to capture any users.dezernat
drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table
with RLS (any-authenticated read, global_admin write).
- models.User.Dezernat dropped. Department / DepartmentMember →
PartnerUnit / PartnerUnitMember.
- DepartmentService → PartnerUnitService (file renamed via git mv to
preserve blame). Every mutation now opens a tx and emits a
partner_unit_events row in the same tx (created/updated/deleted/
member_added/member_removed). Update emits before/after snapshots;
Delete emits BEFORE the cascade so the FK still resolves, then
ON DELETE SET NULL keeps the historical row.
- /api/departments/* → /api/partner-units/*. Handlers renamed.
- New /admin/partner-units page handler stub.
- AuditService UNIONs the new partner_unit_events source as a 4th
branch; handler accepts AuditSourcePartnerUnitEvents.
- user_service: drop dezernat from CreateUserInput / UpdateProfileInput
/ AdminCreateInput / AdminUpdateInput. CreateUserInput gains
PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and
the membership row + audit event are inserted in the same tx.
- Settings tab aliases drop dezernat/department.
- Legacy /dezernate and /departments now redirect to
/admin/partner-units (admins only see it; non-admins land on the
forbidden bounce).
go build / vet / test compile clean.
Schema additions for the new digest-style reminder system:
paliad.users
- reminder_warning_offset_days INT NOT NULL DEFAULT 7, range 1..30
Per-user customisation of how many days before each deadline the heads-up
email fires. 7 matches the prior Monday-weekly behaviour.
- escalation_contact_id UUID NULL FK paliad.users(id) ON DELETE SET NULL
Optional override of the escalation channel for overdue / DRINGEND mail.
NULL means "fall back to global_admins". UI dropdown deferred to a
follow-up task per m's 2026-04-28 decision; column ships now to avoid
a second migration. Self-reference forbidden by CHECK constraint.
paliad.reminder_log
- slot TEXT NULL, slot_date DATE NULL — digest dedup keys.
- reminder_type CHECK widened to admit 'morning_digest' / 'evening_digest'
alongside the legacy 'overdue' / 'tomorrow' / 'weekly' values.
- Partial UNIQUE INDEX (user_id, slot, slot_date) WHERE slot IS NOT NULL
enforces "one digest per user per slot per local-date". Legacy rows
with slot IS NULL are unaffected.
CLAUDE.md updated with a §Phase status note pointing to the design doc
and explaining the deferred Settings-UI dropdown for escalation_contact_id.
Migration is fully additive and idempotent (IF NOT EXISTS / DROP-then-ADD
on named constraints). Down migration reverses the schema cleanly; any
'morning_digest' / 'evening_digest' rows must be deleted before downgrading.
Four standalone bugs from the 2026-04-27 polish audit (PR-E batch).
F-02 — /admin/team search input: long placeholder ("Nach Name oder
E-Mail suchen…") visually overlapped the absolutely-positioned count
badge ("31 / 31") because .glossar-search reserved only 0.75rem of
right padding. Bumped padding-right to 4.5rem so the badge sits in its
own gutter — same fix protects every other use of the .glossar-search
shell (admin team, glossary, etc.) without touching individual pages.
F-03 — /api/departments?include=members 500 regression. Migration 020
renamed paliad.dezernat_mitglieder → department_members but missed the
dezernat_id column on prod youpc. Application code (DepartmentService.
ListWithMembers / ListMembers / AddMember / RemoveMember) selects
department_id, which doesn't exist there → "column does not exist"
500. New migration 024 renames the column idempotently, plus the
indexes/constraints/policies that postgres did not auto-rename when
their table was renamed (departments_pkey, departments_office_idx,
departments_lead_idx, departments_lead_user_id_fkey,
departments_office_check, department_members_pkey,
department_members_user_idx, department_members_department_id_fkey,
department_members_user_id_fkey, departments_select / _write,
department_members_select / _write). Every rename uses a DO block that
swallows undefined_object / undefined_column so the migration is a
no-op on dev DBs that already had English names from migration 018.
Down step puts the German names back symmetrically.
F-08 — Project detail tabs (/projects/{id}/Verlauf|Team|…) used
href="#", so middle-click and "open in new tab" were broken even
though the SPA already mirrored the canonical path via
history.replaceState. initTabs() now sets each tab anchor's href to
/projects/{id}/{tab} (id resolved from the URL when the project hasn't
loaded yet) and only intercepts plain left-clicks — middle/ctrl/meta/
shift/alt fall through to the browser. Backend gains the previously-
missing /projects/{id}/history and /projects/{id}/children server
routes (both bound to handleProjectsDetailPage like every other tab),
so opening the URL in a fresh tab no longer 404s.
F-09 — /projects?view=tree was silently ignored: viewMode was hard-
coded to "flat" and the URL was never read. parseInitialView() now
seeds viewMode from ?view=, initFilters() syncs the dropdown to the
parsed value before binding the change handler, and changing the
dropdown rewrites the query string via history.replaceState (default
"flat" stays implicit to keep the canonical path clean). Bookmarks,
dashboard links, and copy-shared URLs round-trip correctly.
Verification:
- /api/departments?include=members live-tested after applying 024 to
youpc: returns 200 with members enriched.
- go build ./... + go vet ./... + go test ./... clean.
- bun run build clean.
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'
Reminders used to fire whenever the hourly ticker happened to scan after
a user's first eligible event — m got mail at 02:28. We now gate delivery
to a user-chosen hour-of-day in their local timezone.
* Migration 022 adds reminder_morning_time / reminder_evening_time /
reminder_timezone (defaults 09:00, 16:00, Europe/Berlin).
* New "due_today_evening" reminder kind with its own template — fires only
for due_date = today AND status = pending, in the evening slot.
* Reminder service computes user-local hour each tick and skips users
outside their slot. SQL widens to a 3-day band; in-process filter
narrows to per-user local date.
* Settings → Notifications gains time inputs and a timezone field.
* Tests: pure (inSlot, slotForKind, matchesLocalDueDate) plus a live-DB
TestReminderSlots covering morning, evening, outside-slot, and the
completed-deadline case.
Migration 020 renamed paliad.can_see_projekt → can_see_project (and
notiz_is_visible → note_is_visible) via ALTER FUNCTION but never rewrote
the bodies. On production the bodies still queried paliad.projekte /
projekt_teams / fristen / termine / projekt_events — all of which were
dropped or renamed in 018+020. Every RLS-enforced read against the new
English tables exploded with `relation "paliad.projekte" does not exist`,
breaking /api/projects, /api/deadlines, /api/appointments etc.
Same problem for the trigger functions paliad.projekte_sync_path() and
paliad.projekte_rewrite_subtree() — kept their German names and German
bodies; the triggers on paliad.projects still pointed at them.
Migration 021:
* DROP FUNCTION ... CASCADE drops can_see_project / note_is_visible
along with their 21 dependent RLS policies (whose names were still
German on prod: projekte_*, projekt_teams_*, fristen_all, termine_*,
parteien_all, dokumente_all, projekt_events_all, notizen_all,
checklist_instances_*).
* Recreates the two functions with English bodies + English parameter
names and rebuilds every dependent policy under its canonical
English name (matching migration 018).
* Drops the German trigger functions/triggers on paliad.projects and
recreates them as projects_sync_path / projects_rewrite_subtree.
Idempotent on a fresh DB (where everything is already English): the
CASCADE drops the same policies and the recreate produces an identical
end state.
Verified by running the up.sql in BEGIN/ROLLBACK against the actual
youpc prod Postgres — 21 policies dropped, 21 recreated, function
bodies now reference paliad.projects / project_teams / etc.
Refs: tests/smoke-auth-2026-04-25.md (Bug 3, root cause for Bugs 1+2).
Three rename leftovers from t-paliad-025 fixed in one shot:
1. TSX/TS element ID mismatches — every page that worked via getElementById was
broken because the client TS was renamed (e.g. project-title) but the TSX
still used the German id (akte-title), so $() / getElementById would throw
"missing element". Renamed `akte-*` → `project-*`, `termin-akte-*` →
`termin-project-*`, `frist-akte-*` → `frist-project-*`, `new-instance-akte`
→ `new-instance-project`, `frist-filter-akte` → `frist-filter-project`,
`termin-filter-akte` → `termin-filter-project` across all affected TSX.
2. Migration 020 idempotency — every ALTER TABLE/FUNCTION/COLUMN now lives in
a DO $$…EXCEPTION WHEN undefined_table/column/function THEN NULL block.
Production already has English names (manually patched), and the rewritten
migration 018 creates English names directly on a fresh DB; the old
non-defensive 020 would have failed in both scenarios. Down migration
wrapped the same way for symmetry.
3. PostgREST endpoint names — `checklists_feedback` and `courts_feedback`
referenced tables that don't exist; migration 020 renames the source
tables to `checklist_feedback` / `court_feedback` (singular, matching
`link_feedback`). Handlers now point at those. `glossary_suggestions`
reverts to `glossar_suggestions` — that table lives in the shared public
schema (pre-paliad era) and is not under our migration control.
Verified: go build / go vet / go test / bun run build all clean. Migration 020
dry-runs clean against current production state inside a transaction.
knuth's rename (t-paliad-025) changed all Go code and URLs to English
but forgot the DB migration. Production tables still German (fristen,
termine, projekte etc.) while code references English names (deadlines,
appointments, projects). This caused reminder_service to fail with
'relation paliad.deadlines does not exist'.
- einstellungen.tsx: fourth tab 'Dezernat'. My Dezernat card (name, office,
lead, member list). Admin-only 'Dezernate verwalten' section with table
(name/office/lead/members/delete) + 'Neues Dezernat anlegen' form behind
a details summary. Admin controls hidden unless /api/me.role='admin'.
- client/einstellungen.ts: loadDezernatTab() fetches /api/dezernate, then
per-dezernat /api/dezernate/{id}/members to resolve membership for the
'My Dezernat' view. Admin table with delete-with-confirm. New-Dezernat
form posts to /api/dezernate; inserts into in-memory list on success.
TabName + TABS + loadedTabs dispatcher extended.
- i18n: dezernat.* keys (DE+EN) — heading/subtitle/admin section/table
columns/form labels/error strings.
- Migration 019: best-effort seed of paliad.dezernate + dezernat_mitglieder
from paliad.users.dezernat free-text. Each distinct non-empty name
becomes one Dezernat (office = MIN(members.office)); every user whose
free-text matches joins. free-text column preserved so a second pass
can clean it up later. down-migration only deletes rows we inserted
(matches name = btrim(user.dezernat)), leaves admin-created Dezernate
alone.
go build/vet/test + bun run build all clean.
Branch mai/cronus/implement-data-model-v2 now covers all four phases.
paliad.projekte — single self-referential tree (types: client/litigation/patent/case/project).
Materialised path (text, '.'-joined UUIDs, inclusive of self) + trigger maintenance.
ClientMatter numbers (client_number + matter_number, 7-digit CHECK each) and netdocuments_url.
paliad.projekt_teams — team membership with inherited flag (writes = false; services annotate
inherited rows on read by walking up path). Unique (projekt_id, user_id).
paliad.dezernate + paliad.dezernat_mitglieder — structural partner units (orthogonal to project
teams; informational office).
paliad.users — adds additional_offices text[] for partners across multiple offices.
Visibility simplified to team-based only: can_see_projekt() = admin OR direct/ancestor team
membership (path @> ancestor). owning_office GONE from every projekt — location is no longer
an access gate. Per head (2026-04-20): cases associate with lead partners, not offices.
Data migration: akten → projekte (same UUIDs, type='case', parent NULL orphans). Creator →
projekt_teams(role='lead'); collaborators → projekt_teams(role='associate'). Orphan akten with
no creator + no collaborators become admin-only until reassigned.
Child FK rename: akte_id → projekt_id on parteien, fristen, termine, dokumente, akten_events,
notizen, checklist_instances. No data move (same UUIDs). akten_events renamed to projekt_events.
notizen keeps its polymorphic 4-FK shape.
paliad.akten dropped. can_see_akte() and notiz_is_visible(akte) replaced.
Down-migration restores v1 schema best-effort: only type='case' projekte come back as akten;
non-case tree rows are lost (documented). owning_office backfilled from creator's primary office.
Followups (Phase 2): replace AkteService with ProjektService + TeamService + DezernatService,
wire creator-auto-lead into Create path, update all child services to use projekt_id.
No code changes in this commit — server will fail to build/start until Phase 2 lands.
Unified /einstellungen page replaces the standalone CalDAV screen. Three
tabs today (Profil / Benachrichtigungen / CalDAV); adding more is additive
(one <a> in the tab nav, one <section> panel, one loader). Tab switching
is client-side from ?tab=<name> — default tab is Profil.
Profil tab lets users fix onboarding data without admin intervention:
display name, office, role, Dezernat, language. Email is read-only (the
source of truth is auth.users and an account-level change is out of
scope for the settings page).
Benachrichtigungen tab exposes deadline reminder preferences as a master
toggle plus three per-kind sub-toggles (overdue / tomorrow / weekly).
Preferences land in paliad.users.email_preferences (JSONB); missing keys
are treated as opt-in so existing users keep the behaviour they had
before the page shipped.
CalDAV tab is the old /einstellungen/caldav screen ported inline.
/einstellungen/caldav now 301-redirects to /einstellungen?tab=caldav so
bookmarks keep working.
Backend:
- PATCH /api/me (handlers/users.go) mutates the caller's paliad.users
row. Attempts to include "email" in the body return 400 — the field is
always server-authoritative.
- UserService.UpdateProfile builds a dynamic UPDATE from the pointer
fields supplied; omitted keys are left untouched. Re-uses the
admin-bootstrap guard for role changes.
- GetByID SELECT now includes lang + email_preferences so /api/me
returns the data the settings page needs without a second round-trip.
- ReminderService consults email_preferences before sending — the helper
reminderEnabled covers the master switch and per-kind overrides; corrupt
JSON falls back to on so a bad row can't silence reminders.
- Migration 017 adds email_preferences jsonb NOT NULL DEFAULT '{}' and
upgrades lang from nullable (from 016) to NOT NULL DEFAULT 'de' with a
one-shot backfill. Down restores the nullable lang and drops
email_preferences.
Model change: User.Lang moved from *string to string — it's NOT NULL in
the DB now, so the indirection was carrying no information. Inviter.Lang
and reminder row structs followed suit; the templates and callers used
""/"en" comparisons that translate 1:1.
Sidebar: the "Einstellungen" group now links to /einstellungen (instead
of just /einstellungen/caldav); the CalDAV sub-item is folded into the
tab nav on the page itself.
Tests: reminderEnabled has table-driven coverage (master switch,
per-kind, corrupt JSON, non-bool values). DB-backed user tests still
skip without TEST_DATABASE_URL as before.
Verified: go build ./..., go vet ./..., go test ./..., bun run build —
all clean.
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
html/template rendering, branded base layout + content templates, silent
no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
overdue / due tomorrow / due within the week (Monday digest). Dedup via
paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
whitelist, in-memory 10/day/user rate limit, audit row in
paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
question; current pilot keeps identity mails on Supabase default sender.
Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
- Drop the Praxisgruppe field from the onboarding form. Every Paliad user
is in patent practice, so the field carried no signal. The DB column is
retained for future use (set to NULL on insert).
- Switch role from a 4-value enum (partner/associate/pa/admin) to free
text with a <datalist> of suggestions (Partner, Associate, PA, Of
Counsel, Referendar/in, Trainee, wiss. Mitarbeiter/in, Sekretariat).
German firms have many roles beyond the original four.
- Add an optional Dezernat field — the team led by a specific partner.
Free text, no FK (the partner may not be registered yet).
Backend:
- Migration 015: drop the role enum CHECK, replace with non-empty CHECK;
ADD COLUMN dezernat text.
- UserService.Create: drop validRoles map, require non-empty role string,
trim and persist Dezernat. Admin bootstrap gate unchanged.
- models.User gains Dezernat *string; userColumns SELECT updated so
/api/me returns it.
Frontend:
- onboarding.tsx: replace role <select> with <input list=...>; add
dezernat input; remove practice_group.
- onboarding.ts: send dezernat (if non-empty), require role.
- i18n: add onboarding.role.placeholder, onboarding.dezernat[.placeholder],
onboarding.error.role; remove the role.* enum and practice_group keys.
Checklisten move from one-per-slug localStorage state to a template/instance
model. A user creates multiple named instances of each template (UPC SoC,
EPA Einspruch, …), each with its own checkbox state in paliad.checklist_instances
and an optional akte_id for office-wide visibility.
- Migration 014: paliad.checklist_instances + RLS mirroring the Termine
pattern (akte_id nullable → creator-only; akte_id set → can_see_akte gate).
- Static template data moves out of internal/handlers into internal/checklisten
so both handlers and the new ChecklistInstanceService can reference it
without an import cycle.
- ChecklistInstanceService: CRUD + state merge via `state || $n::jsonb`
so concurrent checkbox toggles don't clobber each other. Reset clears
state to {}. Akte-linked mutations append akten_events audit rows.
- Handlers: GET/POST /api/checklisten/{slug}/instances, GET/PATCH/DELETE
/api/checklisten/instances/{id}, POST .../reset, GET /api/akten/{id}/checklisten.
- /checklisten/{slug} redesigned to show template metadata + instance
table + "Neue Instanz" modal (with optional Akte dropdown). The
interactive checkboxes move to /checklisten/instances/{id} where the
state is DB-backed and Reset posts to the server. Fixes the original
Reset button regression — it now operates on real server state rather
than silently failing client-side.
- Akten detail grows a Checklisten tab listing linked instances with
progress bars; only loads on tab activation.
- localStorage-based progress removed from the overview grid (state no
longer lives there).
- DE + EN i18n keys added.
Verified: bun run build clean; go build ./...; go vet ./...; go test ./...
all green.
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.
Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
Termine follow AkteService.GetByID. Every Akte-attached mutation appends
an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
to start with malformed key; unset key leaves CalDAV disabled and all
/api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
/api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.
Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
test-connection button, status card, sync log table, delete button that
purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.
Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password
t-paliad-010
- Delete internal/calc/deadlines.go/deadline_rules.go/holidays.go (ported to services)
- fristenrechner handler routes through FristenrechnerService when pool present
- Returns 503 with German message when DATABASE_URL unset (page still renders)
- Migration 012: add name_en columns + seed 9 UI-facing proceeding types
- Commit captures cronus's work after session termination
Production crash when DATABASE_URL was first set on the shared Supabase:
pq: column "dirty" does not exist at column 17 (42703)
in line 0: SELECT version, dirty FROM "public"."schema_migrations"
Root cause: the Supabase instance already had a differently-shaped
public.schema_migrations (version-only, no dirty column) from another app
or earlier tool. golang-migrate's default tracking table is called
"schema_migrations" and lives in current_schema() (public, since paliad
didn't exist yet at migrator startup). The driver tried to read its own
schema from the foreign table and blew up.
Fix:
1. Set postgres.Config.MigrationsTable = "paliad_schema_migrations" — a
uniquely-named tracker that cannot collide with another app's table.
2. Pre-create the paliad schema before invoking golang-migrate so
subsequent migrations target it cleanly. Idempotent via IF NOT EXISTS.
3. Leave the tracker in `public` (default SchemaName). Rationale: the
first migration's down-step is DROP SCHEMA IF EXISTS paliad CASCADE,
which would take a paliad.schema_migrations tracker with it and break
any subsequent migrate.Up(). Keeping it in public makes down-cycles
safe.
Verified locally:
- Reproduced the collision by creating a public.schema_migrations with
only a version column (matching the production shape) and running the
fixed migrator against it.
- Pre-existing public.schema_migrations untouched (version=42 preserved).
- New public.paliad_schema_migrations created at version=11.
- All 15 paliad.* tables created.
- Idempotent: second migrator run reports ErrNoChange, no double-apply,
seed data unchanged.
- Live tests (TEST_DATABASE_URL) still pass against the collision DB.
Implements docs/design-kanzlai-integration.md §8 Phase B.
Pool & infrastructure:
- internal/db/pool.go — sqlx connection pool via DATABASE_URL
(lazy, sync.Once, returns nil if unset)
- cmd/server/main.go wires pool + services on startup; skips gracefully
if DATABASE_URL unset (existing endpoints still work)
Services (internal/services/):
- holidays.go — ported from KanzlAI. Audit §1.6 fix: replaces unguarded
map with sync.Map of *yearEntry (sync.Once per year), race-safe under
concurrent readers.
- deadline_calculator.go — ported. days/weeks/months + before/after
timing + holiday/weekend adjustment via HolidayService.
- deadline_rule_service.go — ported, DB-backed. List, GetRuleTree,
GetFullTimeline (recursive CTE for cross-type spawns), GetByIDs,
ListProceedingTypes.
- user_service.go — reads paliad.users; GetByID returns (nil, nil) for
users who haven't onboarded yet (safe default = no visibility).
- akte_service.go — new. Office-scoped visibility enforced at the app
layer (defense-in-depth alongside RLS). ListVisibleForUser uses the
visibility predicate directly in SQL so indexes can drive the query.
Create/Update/Delete enforce role gates:
* associates can only create in their own office
* only admins can move an Akte between offices
* only partners/admins can toggle firm_wide_visible
* only partners/admins can delete (soft, status='archived')
Writes an akten_events row on create, status change, firm-wide toggle,
collaborator change.
- parteien_service.go — ported. Visibility inherited from the parent
Akte via AkteService.GetByID gate.
Sentinel errors:
- services.ErrNotVisible → handlers return 404 (never leak existence)
- services.ErrForbidden → 403
- services.ErrInvalidInput → 400
Auth context:
- internal/auth/user.go — WithUserID middleware extracts the `sub` claim
from the Supabase JWT session cookie and injects uuid.UUID into the
request context. Runs after Client.Middleware (which already validated
the cookie expiry). Handlers use auth.UserIDFromContext().
Handlers (internal/handlers/):
- akten.go — full CRUD for /api/akten + /api/akten/{id}/parteien.
All require DB configured (503 otherwise) and authenticated user
(401 otherwise). Returns 404 for non-visible IDs.
- deadline_rules_db.go — GET /api/deadline-rules, GET
/api/proceeding-types-db, POST /api/deadlines/calculate.
The /api/deadlines/calculate endpoint lives alongside the existing
in-memory /api/tools/fristenrechner; Phase C swaps the UI over and
deletes the in-memory rule tree.
- handlers.Register now takes an optional *Services bundle; when
DATABASE_URL unset the DB-backed endpoints return 503 with a clear
error message.
Tests (internal/services/):
- holidays_test.go — Easter algorithm (5 years spot-checked), German
federal holidays, weekend + Neujahr adjustment, concurrent cache
reads under -race.
- deadline_calculator_test.go — days/weeks/months calc, before timing,
Karfreitag→Ostermontag skip (lands on Tue 2026-04-07), batch with
zero-duration rule.
- akte_service_test.go — live DB test behind `TEST_DATABASE_URL` (skip
otherwise). Verifies 4-Akte × 3-user visibility model AND role
enforcement (associate can't delete, can't cross-office-create,
invalid office rejected).
Manual verification:
- `go build ./...` + `go vet ./...` clean
- `go test ./internal/services/ -race` passes (DB tests skip without URL)
- With TEST_DATABASE_URL set, all visibility + role tests pass
- Live HTTP smoke test with forged JWT cookie:
* /api/deadline-rules returns 40 rules
* /api/proceeding-types-db returns 7 types
* /api/deadlines/calculate INF + 2026-04-15 returns calculated deadlines
* /api/akten returns [] (user has no paliad.users row yet — safe default)
* /login, / still work (no regressions)
Implements docs/design-kanzlai-integration.md §8 Phase A.
Schema (paliad.*):
- users (extends auth.users) with office, practice_group, role
- akten with visibility columns: owning_office, collaborators uuid[],
firm_wide_visible (per design §2)
- parteien, fristen, termine, dokumente, akten_events, notizen
(polymorphic notes; notizen_exactly_one_parent CHECK)
- proceeding_types, deadline_rules, holidays (reference data)
- 4 feedback tables re-namespaced from public.* into paliad.*
(handler swap to direct DB is a follow-up; old public tables stay
intact for now and continue serving via PostgREST)
Visibility (paliad.can_see_akte):
- single SQL function, used by every RLS policy
- predicate: firm_wide_visible OR owning_office matches user's office
OR auth.uid() ∈ collaborators OR user is admin
- mirrored at app layer in Phase B (defense in depth)
RLS (real, not permissive):
- akten: visibility predicate; insert restricted to own office or admin;
delete restricted to partners + admins
- parteien/fristen/dokumente/akten_events: inherit via can_see_akte(akte_id)
- termine: personal (akte_id NULL) visible only to creator; Akte-linked
follow visibility predicate
- notizen: paliad.notiz_is_visible() resolves polymorphic parent
- reference tables: SELECT for any authenticated user
- users: SELECT all; UPDATE/INSERT only self
- feedback tables: INSERT for any authenticated user (write-only)
Seed data (ported from KanzlAI seed_upc_timeline.sql):
- 7 proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL)
- 40 deadline_rules (32 UPC + 4 ZPO + 4 cross-type appeal spawns)
including conditional logic: Reply rule code (RoP.029b → 029a) and
Rejoinder duration (1mo → 2mo) flip when CCR active
- 55 holidays (DE federal 2026/2027 + UPC summer 2026 + UPC winter 26/27)
Indexes per audit §3.3 + visibility-predicate hot paths:
- akten: (status, owning_office), (owning_office), partial on
firm_wide_visible, GIN on collaborators
- fristen: (status, due_date), (akte_id)
- termine: (start_at), (akte_id)
- akten_events: (akte_id, created_at DESC)
- notizen: 4 partial indexes per parent type
- users: (office), (role)
Migration tooling:
- golang-migrate/migrate/v4 with embed.FS source
- Migrations live in internal/db/migrations/ (Go embed can't reach
outside the package; this is the conventional Go layout for embedded
migrations)
- Applied at server startup before HTTP listener binds
- DATABASE_URL is optional today (existing knowledge tools work without
DB); becomes required once Phase B services land
- Mock Supabase auth schema for local testing in
internal/db/migrations/_dev/mock_supabase_auth.sql (excluded from
embed pattern by the underscore prefix)
Other changes:
- Dockerfile: bump golang to 1.24, copy go.sum (audit §2.9), rename
binary patholo → paliad
- docker-compose.yml: add DATABASE_URL passthrough
- README.md: rewritten to reflect Paliad brand + Phase A migration system
Verified locally:
- 11 migrations applied cleanly against postgres:16-alpine
- RLS enabled on all 15 paliad.* tables (verified via pg_class.relrowsecurity)
- Visibility predicate verified with 4-case scenario:
- Alice (Munich associate): sees Munich + firm-wide + collab-on (t f t t)
- Bob (Düsseldorf associate): sees Düsseldorf + firm-wide + collab-on (f t t t)
- Carol (Munich partner): sees Munich + firm-wide only (t f t f)
- Anonymous: sees firm-wide only (f f t f)
- migrate down + re-up cycle clean (initial 007 down had ordering bug,
fixed: drop policies before referenced function)
- Existing endpoints (/, /login) return 302 + 200 — no regressions