e76056cfd1483b6a8a099d2cfe0530674cfd2314
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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
|
||
|
|
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 |
||
|
|
95817fe78c |
fix(db): use paliad_schema_migrations tracker to avoid public.schema_migrations collision
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. |
||
|
|
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)
|
||
|
|
1b2ef28334 |
feat(db): Phase A — paliad schema, RLS, migrations, golang-migrate
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 |