Commit Graph

18 Commits

Author SHA1 Message Date
m
3da11bd798 chore(t-paliad-081): doc + dead-code batch (F-5/F-10/F-11/F-15/F-16/F-17/F-18)
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.
2026-04-30 03:42:25 +02:00
m
76785da3f6 feat(t-paliad-070): rename Department → PartnerUnit on the Go side
Backend rename (frontend lands in next commit):
- Migration 026: rename paliad.departments → paliad.partner_units,
  paliad.department_members → paliad.partner_unit_members, junction FK
  department_id → partner_unit_id, plus all constraints/indexes/policies.
  Pre-drop seed re-runs migration 019's logic to capture any users.dezernat
  drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table
  with RLS (any-authenticated read, global_admin write).
- models.User.Dezernat dropped. Department / DepartmentMember →
  PartnerUnit / PartnerUnitMember.
- DepartmentService → PartnerUnitService (file renamed via git mv to
  preserve blame). Every mutation now opens a tx and emits a
  partner_unit_events row in the same tx (created/updated/deleted/
  member_added/member_removed). Update emits before/after snapshots;
  Delete emits BEFORE the cascade so the FK still resolves, then
  ON DELETE SET NULL keeps the historical row.
- /api/departments/* → /api/partner-units/*. Handlers renamed.
- New /admin/partner-units page handler stub.
- AuditService UNIONs the new partner_unit_events source as a 4th
  branch; handler accepts AuditSourcePartnerUnitEvents.
- user_service: drop dezernat from CreateUserInput / UpdateProfileInput
  / AdminCreateInput / AdminUpdateInput. CreateUserInput gains
  PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and
  the membership row + audit event are inserted in the same tx.
- Settings tab aliases drop dezernat/department.
- Legacy /dezernate and /departments now redirect to
  /admin/partner-units (admins only see it; non-admins land on the
  forbidden bounce).

go build / vet / test compile clean.
2026-04-29 22:03:08 +02:00
m
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.
2026-04-29 14:26:04 +02:00
m
495e519475 feat(t-paliad-065): firm-agnostic branding via single FIRM_NAME constant
Paliad ships firm-agnostic per CLAUDE.md ("survives firm renames") but
landing copy, email templates, page titles, and form placeholders still
hard-coded "Hogan Lovells" / "HL Patents". Replaces every user-facing
firm reference with a single source of truth: internal/branding.Name on
the server and frontend/src/branding.ts in the bundle, both reading
FIRM_NAME at startup/build time and defaulting to "HLC".

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

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

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

Verified: go build/vet/test clean; bun run build clean; FIRM_NAME=Acme
override produces "Acme" in HTML and JS bundles end-to-end.
2026-04-28 22:44:06 +02:00
m
765bfe0648 feat(t-paliad-064): bundled-digest reminder service + settings UI (PR-3/4)
Replaces the per-deadline reminder model (overdue / tomorrow /
due_today_evening / weekly templates and four per-kind send paths) with
one bundled digest per (user, slot, local-date) — owner + project leads +
global_admins as audience tiers, three category sections per email.

Service rewrite (internal/services/reminder_service.go):
- RunOnce iterates users, evaluates morning/evening slot per user's tz,
  calls runSlotForUser for each match.
- runSlotForUser checks the slot+date dedup (migration 025), fetches the
  three pending-deadline categories visible to u (overdue / due_today /
  due_warning at u.reminder_warning_offset_days), composes a digest, and
  inserts the dedup row only on successful send.
- Audience filter applied per row in Go: due_warning to owner/lead,
  due_today to owner/lead (+global_admin in evening), overdue to
  owner/global_admin (NOT lead — system failure escalates past the team).
- Subject ladder: ÜBERFÄLLIG / SYSTEMAUSFALL when overdues are in the
  bundle; DRINGEND on evening when due_today still pending; "Frist-
  Erinnerung: N offen" otherwise. EN equivalents.
- Retired sendPerFrist, sendWeekly, deliverFristReminder, deliverWeekly,
  buildSubject, slotForKind, matchesLocalDueDate.

Templates:
- Added deadline_digest.html with three category sections (red/amber/
  neutral), DRINGEND wording on evening, IsOtherOwner attribution row.
- Removed deadline_reminder.html, deadline_due_today.html, deadline_weekly.html.

User schema (Go side):
- models.User gains ReminderWarningOffsetDays (int, default 7) and
  EscalationContactID (*uuid.UUID, nullable).
- userColumns SELECT updated; UpdateProfileInput accepts the new offset
  with 1..30 validation.

Settings → Notifications UI (PR-4):
- New reminder categories: overdue / due_today / due_warning. Legacy
  toggles (tomorrow, due_today_evening, weekly) removed and the legacy
  pref keys are explicitly deleted from the email_preferences object on
  next save so they don't linger.
- New "Vorwarnung (Tage vorher)" input (1..30, required), wired into the
  PATCH /api/me payload as reminder_warning_offset_days.
- Times-section copy refreshed: "Morgen-Slot" / "Abend-Slot (Eskalation)"
  with new hint text reflecting the bundled-digest model.
- DE + EN i18n strings added/updated.

Tests:
- TestCategorize, TestVisibleForCategory, TestBuildDigestSubject lock
  the boundary, recipient-rule, and subject-ladder logic.
- TestRunSlotForUser (live DB, skipped without TEST_DATABASE_URL) covers
  the morning/evening flow, slot+date dedup, and off-slot tick.
- TestRunSlotForUser_EmptyDigest enforces the no-spam rule.
- TestDeliverDigest_RendersTemplate runs the new template on the
  digestRow shape so a typo would fail before any SMTP I/O.
- TestRenderTemplateDeadlineDigest replaces the deleted reminder/weekly
  template tests.

go build/vet/test + bun run build all clean.
2026-04-28 13:17:30 +02:00
m
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'
2026-04-27 14:59:03 +02:00
m
e68ff5b434 feat(reminders): per-user send times + due-today evening sweep (t-paliad-048)
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.
2026-04-27 11:47:10 +02:00
m
3faec6c526 refactor(rename): German→English for backend (tables, types, services, handler files)
t-paliad-025 — Phase 1: backend rename.

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

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

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

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

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

Build + vet + tests clean.
2026-04-20 17:35:38 +02:00
m
9aa8037193 refactor: services — Projekt, Team, Dezernat services (WIP Phase 2)
Models: Akte → Projekt (tree type + parent_id + path + client/matter numbers
+ netDocuments URL + type-specific client/patent/case columns). AkteEvent →
ProjektEvent. FristWithAkte → FristWithProjekt. TerminWithAkte → TerminWithProjekt.
Notiz.AkteID → ProjektID. ChecklistInstance.AkteID → ProjektID. Partei.AkteID →
ProjektID. User adds AdditionalOffices pq.StringArray.

Services:
- NEW projekt_service.go replaces akte_service.go. Adds tree ops: List/GetByID/
  ListChildren/ListAncestors/GetTree. Create auto-adds creator to projekt_teams
  role=lead in same tx. ResolveClientNumber walks path for inheritance.
  Visibility helpers (visibilityPredicate / Positional / Placeholder) centralise
  team-based access check: admin OR any ancestor/direct projekt_teams row.
- NEW team_service.go — AddMember/RemoveMember/ListDirectMembers/
  ListEffectiveMembers (unions direct + inherited via path, dedup by user;
  direct wins)/IsEffectiveMember. Inherited=true set at read time only.
- NEW dezernat_service.go — admin-gated CRUD + member add/remove + user
  membership lookup for settings page.
- frist_service.go → projekt_id everywhere, uses visibilityPredicate. ListFilter.
  AkteID → ProjektID.
- termin_service.go → projekt_id everywhere. CalDAV log reads projekt_events.
- notiz_service.go → projekt_id polymorphic branch; eventProjektID() looks at
  projekt_events; akten_event_id column kept (FK now resolves to projekt_events).
- parteien_service.go → projekt_id.
- checklist_instance_service.go → projekt_id with ClearProjekt flag.
- dashboard_service.go → rewrites all four queries against projekte +
  projekt_events + projekt_teams. Matter/Upcoming/Activity surfaces use
  ProjektID/ProjektTitle/ProjektRef.
- reminder_service.go → joins paliad.projekte, aliases a.reference AS
  akte_aktenzeichen for template compat.

Handlers/tests still reference old API — Phase 2 completion requires handler
rewrite (next commit). Build currently broken in internal/handlers.
2026-04-20 14:46:59 +02:00
m
5fb55164b3 feat: settings page — profile, email preferences, CalDAV as tabs (t-paliad-022)
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.
2026-04-20 13:17:24 +02:00
m
11217f7bfa feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
  html/template rendering, branded base layout + content templates, silent
  no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
  overdue / due tomorrow / due within the week (Monday digest). Dedup via
  paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
  whitelist, in-memory 10/day/user rate limit, audit row in
  paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
  question; current pilot keeps identity mails on Supabase default sender.

Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
2026-04-20 12:34:38 +02:00
m
7c44bbec7e refactor: onboarding form — drop Praxisgruppe, free-text role, add Dezernat (t-paliad-020)
- 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.
2026-04-18 20:26:11 +02:00
m
4c0babb2f3 feat(checklisten): instanceable checklists — DB-backed state, Akte linkage
Checklisten move from one-per-slug localStorage state to a template/instance
model. A user creates multiple named instances of each template (UPC SoC,
EPA Einspruch, …), each with its own checkbox state in paliad.checklist_instances
and an optional akte_id for office-wide visibility.

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

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

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

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

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

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

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

t-paliad-010
2026-04-17 11:59:49 +02:00
m
316dc9f9bf feat(fristen): Phase E — Persistent deadline management UI
Adds the persistent-deadline layer on top of the Phase A schema:

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

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

Visibility, audit and role-gating reuse the Phase B/D primitives —
no new RLS or auth surface.
2026-04-16 17:28:44 +02:00
m
d1909c766e feat: Phase C — Fristenrechner → DB-backed via FristenrechnerService
- Delete internal/calc/deadlines.go/deadline_rules.go/holidays.go (ported to services)
- fristenrechner handler routes through FristenrechnerService when pool present
- Returns 503 with German message when DATABASE_URL unset (page still renders)
- Migration 012: add name_en columns + seed 9 UI-facing proceeding types
- Commit captures cronus's work after session termination
2026-04-16 17:11:02 +02:00
m
bcc4939af2 feat(services): Phase B — sqlx pool, services, Akten/Frist endpoints
Implements docs/design-kanzlai-integration.md §8 Phase B.

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

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

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

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

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

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

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