d913f4fc30e860c41cd8a3c330daff7dd57d86cc
83 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| d913f4fc30 |
feat(builder): B5 — share + promote-to-project wizard (t-paliad-350)
Litigation Builder slice B5 (m/paliad#153 PRD §2.4 + §2.5 + §5.4 + §10). Backend (internal/services/scenario_builder_service.go): - ListSharedWithMe — scenarios shared read-only with the caller (the "Geteilt mit mir" bucket). - PromoteScenario — transactional promote-to-project (PRD §10, no partial promotions). One Postgres tx: INSERT paliad.projects ('case', origin_scenario_id, proceeding_type_id + scenario_flags from the primary triplet) → creator team lead + wizard-selected colleagues → parties → deadlines (filed→completed, planned→pending with computed/actual date, skipped→none) → flip scenario to 'promoted' + promoted_project_id. The primary top-level proceeding + its spawned descendants form the one case file; additional standalone proceedings are reported via ProceedingsSkipped and stay in the scenario. Planned dates come from the injected FristenrechnerService.Calculate; court-set/undated planned events are skipped + counted. - NewScenarioBuilderService gains a *FristenrechnerService dep (wired in cmd/server/main.go; nil in tests that don't promote). Handlers/routes: - GET /api/builder/scenarios/shared, POST /api/builder/scenarios/{id}/promote. Frontend: - builder-shares.ts — share modal (HLC user picker + current-shares list + revoke). - builder-promote.ts — 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten) → POST /promote → navigate to /projects/{id}. - builder.ts — bucketed side panel (Aktiv / Geteilt mit mir / Als Projekt angelegt / Archiviert), read-only chrome (watermark + locked affordances) for shared/promoted scenarios, wired share + promote buttons, deep-link auto-load now covers shared scenarios. - procedures.tsx — enabled buttons, bucket containers, readonly watermark slot. - global.css — modal scaffold, share UI, promote wizard, buckets, readonly state. i18n.ts + i18n-keys.ts — DE+EN keys. Tests: TestScenarioBuilderPromote (live-DB) pins the transactional cascade + readonly-after-promote + re-promote rejection. go build/vet/test + bun build clean. Verified end-to-end via Playwright: Journey E (share → 2nd user read-only watermark + locked canvas, incl. deep-link) and Journey D (promote wizard 3 steps → project created with party → navigate → scenario flipped to promoted). |
|||
| a111a82640 |
feat(docforge): slice 6a — docx authoring core + TemplateStore wiring (t-paliad-349)
The verifiable backend heart of the authoring surface, before the HTTP +
frontend layers.
pkg/docforge/docx/authoring.go:
- ImportForAuthoring(carrier) → AuthoringView{PreviewHTML, Slots}: parses
an uploaded .docx into a run-addressable preview (one
<span class="docforge-run" data-run="N"> per <w:t>, document order)
plus the {{placeholder}} slots already present.
- InjectSlot(carrier, runIndex, selectedText, slotKey) → new carrier:
replaces the selection inside run N with a {{slot_key}} token. Keys on
the selected TEXT (not a byte/UTF-16 offset) so umlauts can't desync the
client selection from the server slice; preview + injection walk runs in
the identical paragraph→<w:t> order so data-run indices line up.
- v1 scope: text slots in body paragraphs; out-of-run / cross-run / not-
found selections return an error the UI turns into a hint.
6 unit tests cover run-addressable preview, slot detection, injection +
round-trip re-import, umlaut/run-targeting, and the error paths (selection
absent, out-of-range run, invalid slot key) — all passing.
Wired PgTemplateStore through the stack (main.go → handlers.Services →
dbServices) so the upcoming authoring endpoints can reach it.
Verification: go build/vet clean, full module test green (13 pkgs), new
files gofmt-clean. The HTTP endpoints + frontend authoring page land next;
their live flow needs the post-merge e2e/manual loop (DB+Supabase).
m/paliad#157
|
|||
| 9679a98666 |
feat(builder): B4 — Akte mode + project-backed scenarios (m/paliad#153)
PRD §2.3 + §10. Implements the dual-write rule (load-bearing complexity per PRD §10): project-backed scenarios mirror flag toggles to paliad.projects.scenario_flags and filed event states to paliad.deadlines, while kontextfrei scenarios continue writing only to paliad.scenario_events. Visible affordances: page-header Akte picker, enabled "Aus Akte" mode tab, Akte banner on the project-backed canvas, cross-surface scenario-flag-changed dispatch + listener for live peer-surface coherence. Backend - ScenarioBuilderService takes ProjectService + ScenarioFlagsService deps so dual-write hits live tables. - CreateScenarioFromProject seeds a scenario from a project: copies proceeding_type_id + scenario_flags, normalises our_side to the builder's binary claimant|defendant axis, surfaces existing rule-bound deadlines as scenario_events (filed when completed, planned otherwise). - PatchProceeding on a project-backed top-level triplet dual-writes scenario_flags to projects.scenario_flags via flagDeltaFromBuilder. - PatchEvent transitioning to state='filed' on a project-backed scenario upserts paliad.deadlines (status='completed', completed_ at, source='rule') inside the same tx as the scenario_events UPDATE — canvas and project surfaces never diverge mid-flight. - POST /api/builder/scenarios/from-project handler wires the entry point. Frontend - builder-akte.ts: project list fetch + dropdown render, Akte banner, createScenarioFromProject POST helper. - builder.ts: mode branching — picking an Akte (search hit or page-header pick) creates a project-backed scenario and loads it; loaded scenarios reflect their origin_project_id on the picker + banner; flag toggles on Akte-backed top-level triplets dispatch scenario-flag-changed so the Verfahrensablauf strip / project surfaces refresh; the builder listens to inbound scenario-flag- changed and refetches its scenario when the changed project matches origin_project_id. - procedures.tsx: enable the previously-disabled Aus Akte tab. - i18n + CSS: builder.akte.banner.prefix key (DE+EN); lime-tinted banner styling. Tests - TestScenarioBuilderAkteDualWrite (live DB) pins the dual-write contract: Akte flag toggle → projects.scenario_flags updated, Akte filed event → deadlines row inserted; kontextfrei flag toggle leaves projects.scenario_flags untouched, kontextfrei filed event leaves deadlines untouched. - Existing TestScenarioBuilderService passes against the new signature (nil deps short-circuit dual-write paths). Verification: go test ./... + go vet ./... + bun run build all clean. Playwright smoke against the static dist build confirms the Akte tab + picker render correctly, fetchAkteProjects fires on mount, and the scenario-flag-changed CustomEvent dispatches + receives without runtime errors. t-paliad-347 |
|||
| 0f3c30a647 |
feat(scenario-builder): B0 schema foundation + minimal API (m/paliad#153)
t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema + RLS land, dev-only test route exercises the surface, no user-facing change. B1 wires the actual builder UI on top. Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows in prod, safe to relax): - paliad.scenarios gets owner_id / status / origin_project_id / promoted_project_id / stichtag / notes. spec drops NOT NULL and the scenarios_unique_per_scope constraint drops (the builder allows multiple scratch + Unbenanntes Szenario rows per user). - New tables: scenario_proceedings, scenario_events, scenario_shares. - paliad.projects.origin_scenario_id for the promote-to-project audit trail (the FK lands now; the wizard ships in B5). - paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering owner / share / global_admin / two legacy paths. - Replacement RLS on scenarios + RLS on the three new tables; legacy service + handlers stay live and unchanged. PRD §5.1 deviations called out in the migration header: - proceeding_type_id is integer (live schema), not uuid (PRD draft). - FK target is paliad.users, matching the rest of paliad's schema. Go surface: - ScenarioBuilderService — list/create/get-deep/patch scenarios, add/patch/delete proceedings, add/patch/delete events, add/delete shares. Writes wrap in transactions with set_config( paliad.audit_reason, ..., true) per event_choice_service.go pattern. - /api/builder/scenarios/* — handlers register under a builder/ prefix so the legacy /api/scenarios surface still works. - /dev/scenario-builder — single-page HTML form gated to PaliadinOwnerEmail, exercises the B0 surface without Postman. - Live-DB integration test (TEST_DATABASE_URL gated) covers create + list + deep-get + share + visibility negatives + patch. Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against the live DB before commit; end-to-end sanity (insert chain + CHECK constraints + CASCADE-on-delete) verified via the Supabase MCP. bun build clean. go vet + go test -short ./... green. |
|||
| d36cc9ee15 |
feat(deadline-system): P0 — per-project scenario_flags SSoT (m/paliad#149)
Phase 2 P0 of the deadline + procedural-events revision. Establishes
paliad.projects.scenario_flags (jsonb) + paliad.scenario_flag_catalog as
the single source of truth for per-project scenario state — replacing
the three fragmented stores athena flagged (project_event_choices,
scenarios.spec, DOM-only). All three were empty per the audit so no
data migration is needed.
The jsonb map carries two key shapes:
* named flags (whitelist via scenario_flag_catalog) — today
with_ccr / with_amend / with_cci
* per-rule selection deviations of shape "rule:<uuid>" — wired up
here for validation; the consumer UI lands in P3
Endpoints:
GET /api/projects/{id}/scenario-flags
PATCH /api/projects/{id}/scenario-flags
PATCH semantics: bool = write; null = delete (priority-driven default
returns); missing key = leave alone. The service validates every key
on write (catalog lookup + UUID rule-membership + mandatory-cannot-be-
deselected) before persisting, so a single bad key fails the whole
patch.
Frontend bind: new scenario-flags.ts client module + Mode B's flag
checkboxes (ccr-flag / inf-amend-flag / rev-amend-flag / rev-cci-flag)
now hydrate from / persist to the project's scenario_flags on every
toggle. Kontextfrei (no project) is unchanged. Cross-surface coherence
via a scenario-flag-changed CustomEvent (peer surfaces — Verfahrens-
ablauf strip, Mode B result-view — will subscribe in P3).
Mig 154 is audit-defensive (set_config of paliad.audit_reason); no
audit trigger fires on paliad.projects today but a future one will
inherit the reason. Seeds the three known flags. CHECK constraints
enforce the top-level shape (jsonb_typeof = 'object') and the
catalog key pattern (lowercase, not 'rule:%' prefix).
Verified against the live DB: 18 projects default to '{}', catalog
has 3 rows, applied_migrations advanced to 154.
Design: docs/design-deadline-system-revision-2026-05-27.md §2.3, §2.4a,
§4.1, §5 (P0 row). t-paliad-331.
|
|||
| 6e0961cc30 | Merge: Composer Slice C — building blocks library + section picker (mig 149) (m/paliad#141) | |||
| ee98db94fa |
feat(submissions): Composer Slice C — building blocks library (m/paliad#141)
Per the design at docs/design-submission-generator-v2-2026-05-26.md §8
and the Q2 / Q9 ratifications:
- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
No building_block_id reference is stored on submission_sections.
- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
/ global.
Schema (mig 149):
- paliad.submission_building_blocks — library catalog. Columns: slug,
firm (NULL = cross-firm), section_key (binds to one section kind),
proceeding_family (NULL = any), title_de/_en + description_de/_en
+ content_md_de/_en, author_id, visibility (CHECK in 4-tier set),
is_published, created_at, updated_at, deleted_at (soft delete).
RLS: coarse-grained SELECT — every authenticated user sees
non-deleted non-private rows + own private rows. Tier-specific
predicate (private/team/firm/global) applied in Go-layer service so
semantics evolve without RLS migrations. Mutations admin-only (no
RLS write paths).
- paliad.submission_building_block_admin_versions — append-only
history per block, retention=20. Admin-side only; NOT referenced
from submission_sections (per Q2's plain-text-paste model). Exists
so accidental delete / overwrite are recoverable.
Backend:
- internal/services/submission_building_block_service.go (~510 LoC):
BuildingBlockService. ListVisible applies tier predicate at query
time (private = author_id match; firm = firm column NULL OR matches
branding.Name; team = author shares a project_team with caller via
paliad.project_teams self-join; global = open). ListAllForAdmin
drops the predicate. Create + Update + SoftDelete + RestoreVersion
all transactional; appendVersionTx writes one audit row +
GC-deletes anything past the retention=20 horizon in the same tx.
InsertIntoSection (the paste mechanic) clones content_md_<lang>
into the section row with a "\n\n" separator if section already has
content. NO building_block_id stamped per Q2.
- internal/handlers/submission_building_blocks.go (~480 LoC): nine
handlers split between the lawyer-facing picker (list, insert) and
the admin editor (list, get, create, update, delete, list-versions,
restore-version, page). buildingBlockUpdateInput uses presence-
tracking UnmarshalJSON for the four nullable fields (firm,
proceeding_family, description_de/_en) so PATCH can distinguish
"no change" from "set to null".
- Routes registered: lawyer-facing under /api/submission-building-blocks,
admin-gated under /api/admin/submission-building-blocks/* and
/admin/submission-building-blocks (page).
- Wiring: handlers.Services + dbServices + cmd/server/main.go all
gain SubmissionBuildingBlock. NewBuildingBlockService takes the
branding.Name firm hint for the visibility predicate.
Frontend:
- frontend/src/admin-submission-building-blocks.tsx (~85 LoC):
three-pane admin shell (list / editor / version log) registered
in build.ts.
- frontend/src/client/admin-submission-building-blocks.ts (~370
LoC): admin client — list paint, edit form (slug + firm +
section_key + proceeding_family + title/desc/content per lang +
visibility radio + is_published toggle), per-block version log
with restore button. Bilingual labels.
- frontend/src/client/submission-draft.ts: per-section "+ Baustein"
button on the Composer editor toolbar (Slice B substrate gets one
more affordance). openBlockPicker opens a modal filtered to the
section's section_key, 200ms-debounced search by free text against
title/description/content. Click a hit → POST insert-into-section
→ section row's content_md_<lang> gains the block's content
appended at the end (Q2's plain-text paste semantic, no lineage).
- ~240 LoC of CSS: modal overlay + picker rows with tier-colored
visibility chips + admin editor 3-pane grid + form rows + version
list.
- 12 new i18n keys × 2 langs (admin.building_blocks.*).
Tests:
- TestValidVisibility (8 cases including case-sensitivity + empty).
- TestAppendBlockContent (8 cases covering empty-existing / empty-
addition / whitespace-only / trailing newline collapse).
- TestBuildingBlockVisibilityConstants pins the 4 string literals
against drift (RLS predicate + DB CHECK depend on them).
Build hygiene: go build/vet/test -short clean; bun run build clean
(2906 i18n keys, data-i18n scan clean).
Hard rules per ratifications honoured:
- Q2: no building_block_id lineage on sections (paste is plain text).
- Q9: 4 visibility tiers (private/team/firm/global).
- NO behavior change for pre-Composer drafts (the picker just doesn't
show — section list is hidden for base_id NULL drafts).
- {{rule.X}} aliases preserved (block content goes through the same
v1 placeholder pass on export as section prose).
NOT in scope per Slice C brief:
- User-authored private blocks (Slice C ships admin curation only;
any-user create is a follow-up).
- Tier promotion review workflow (admin sets tier directly today).
- Per-section "where is this block used" reverse lookup (no lineage
to query).
- Slice D's rich-prose features (headings, lists, blockquote) still
Slice D's job; this Slice doesn't extend the MD walker.
t-paliad-315 Slice C
|
|||
| 987db27831 | Merge: t-paliad-305 — Slice B.4 destructive drop: paliad.deadline_rules retired, INSTEAD OF triggers on view (mig 140, snapshot pre_140 same-TX) (m/paliad#93) | |||
| 1129baba7a |
feat(db,services): Slice B.4 destructive drop — paliad.deadline_rules retired, INSTEAD OF triggers on view route writes (mig 140, t-paliad-305 / m/paliad#93)
Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.
Pre-flip drift verified clean against prod:
deadline_rules=231 == sequencing_rules=231 == procedural_events=231
legal_sources=87
missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0
* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
Single TX, audit-first:
1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
(precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
2. Final reconciliation UPDATE on paliad.deadlines (no-op when
drift is already 0; defensive against last-minute writes).
3. DROP TRIGGER deadline_rules_audit_aiud.
4. Re-point FKs to sequencing_rules:
- paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
- paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
(the id values are identical — sr.id inherited dr.id at mig 136.)
5. DROP COLUMN paliad.deadlines.rule_id.
6. DROP TABLE paliad.deadline_rules.
7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
paliad.deadline_rules_unified. Triggers route writes into the
three new tables in the same TX, preserving the legacy column
shape on the wire so RuleEditorService SQL only needs a
table-name swap, not a structural rewrite. Synthetic-code mint
expression is byte-identical to mig 136 + the B.2 dual-write
helper. POST assertions confirm the table is gone, the column
is gone, and the snapshot matches.
Trigger design notes (1:N caveat documented in-trigger):
- PE identity columns (code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id) mirror from
the writing sequencing-rule.
- PE lifecycle columns (lifecycle_state, published_at, is_active)
deliberately do NOT mirror — a draft sequencing-rule cloned from
a published source shares the source's PE; we don't want the
clone's 'draft' lifecycle to leak back onto the source's PE.
Practical bound today (1:1 corpus); explicit comment in-trigger
for the eventual 1:N pattern.
* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
Best-effort restore from the snapshot. Triggers / indexes /
CHECK constraints from historical migrations are NOT replayed;
operator must reapply 078/079/091/095/098/122/128/134/135 to
bring the restored table to working shape. The down path is for
catastrophic recovery, not casual revert.
* internal/services/rule_editor_service.go —
Six syncDualWriteFromDeadlineRule(...) calls removed (the
INSTEAD OF triggers now do the fan-out). Five
INSERT/UPDATE paliad.deadline_rules statements (Create,
UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
flipLifecycle) renamed to paliad.deadline_rules_unified —
trigger handles the routing.
* internal/services/rule_editor_orphans.go — ResolveOrphan no
longer writes deadlines.rule_id (column dropped). Sets
sequencing_rule_id directly + derives procedural_event_id from
the matching sequencing_rules row in the same UPDATE statement.
* internal/services/deadline_service.go — deadlineColumns now
lists sequencing_rule_id (Deadline.RuleID still binds to it via
the db tag rename below). Update path's appendSet("rule_id",…)
flipped to appendSet("sequencing_rule_id",…) and post-write
derivation moved to the renamed syncDeadlineProceduralEventID
helper.
* internal/services/projection_service.go,
internal/services/submission_vars.go — `WHERE rule_id = $X`
reads on paliad.deadlines flipped to sequencing_rule_id.
* internal/models/models.go — Deadline.RuleID db tag changed from
"rule_id" to "sequencing_rule_id". Field name + JSON name kept
for backward compat with the frontend and existing Go callers;
semantic value is identical (same UUID).
* internal/services/dual_write.go — Massively trimmed.
Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
StartDualWriteDriftCheckLoop. All referenced
paliad.deadline_rules which no longer exists.
Kept (renamed): syncDeadlineProceduralEventID — derives
procedural_event_id from sequencing_rule_id after any
DeadlineService.Update that touched the back-link.
* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
bootstrap call (and its `time` import that only that call
needed). Comment notes the retirement.
* internal/services/dual_write_test.go — Removed the final
CheckDualWriteDrift assertion in
TestDualWrite_RuleEditorLifecycle (function deleted). The
per-step asserts against procedural_events / sequencing_rules
/ legal_sources cover the same contract by direct query.
Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
only).
Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
|
|||
| f963b0df34 |
feat(submissions): Composer Slice B — editable prose sections + anchor-spliced render (m/paliad#141)
The "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.
Backend additions:
- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
walker. Per the head's Slice B brief, scope is paragraphs +
bold/italic + blank-line spacing. Placeholders pass through
unchanged for the v1 substitution pass. CRLF normalisation; nested
formatting (***bold-italic***); two delimiter forms (* and _);
XML-escaping for &/</>; explicit empty-paragraph emit so blank
lines round-trip. 12 unit tests.
- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
service. Pipeline: ConvertDotmToDocx pre-pass → extract
word/document.xml → render each included section's content_md_<lang>
→ splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
the body → strip anchors for excluded sections → append unanchored
sections before <w:sectPr> → repack zip → run v1 placeholder pass.
RE2-friendly anchor scanner walks markers in body-order and matches
open/close pairs with a stack (handles unbalanced anchors
defensively). 6 unit tests covering anchor-mode splice,
append-mode-no-anchors, excluded-section drop, placeholder
resolution, lang column pick, order_index ASC.
- internal/services/submission_section_service.go: SectionPatch +
Update method. Six optional fields (content_md_de/en, included,
label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
RLS-filtered miss.
- internal/handlers/submission_sections.go (NEW, ~150 LoC):
PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
cross-check. 404 on both missing-draft and section-belongs-elsewhere
paths.
- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
→ existing firmSkeletonSubmissionSlug, neutral → existing
skeletonSubmissionSlug.
- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
branches on draft.BaseID. When set AND base + bytes + sections all
resolve → Composer pipeline. Else v1 fallback render path stays.
Audit metadata jsonb gains "composer": true + "base_id" flag when
composer was used.
Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
inside section content).
Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
contentEditable per included section with a per-section B/I
toolbar. Per-section autosave debounced 500ms; mousedown handlers on
toolbar buttons preserve editor focus mid-command. domToMarkdown
walks the contentEditable's DOM tree back to Markdown source-of-
truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
→ newline). Updated state.view.sections in-place on PATCH success
without re-painting (avoids focus-stealing on every keystroke);
re-paints only on structural changes (included toggle, label edits,
order changes).
- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
section via PATCH. flushSectionAutosave on blur force-flushes
pending edits so leaving an editor doesn't strand unsynced changes.
- styles/global.css: editor surface (contentEditable area with focus
ring + placeholder), toolbar buttons (B/I 1.8rem squares),
per-section "Hide"/"Include" toggle in the head row.
- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
500ms. Letztes Layout in Word."
Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
sectPr → firm header/footer rIds): blob SHA
f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
(default true) so future regens emit composer-ready bodies. The
_firm-skeleton.docx regen was done via a one-off /tmp helper since
the gen-hl-skeleton-template script requires the proprietary .dotm
source which lives in HL/mWorkRepo; extending that script to accept
an existing .docx as input is a follow-up cleanup.
Build hygiene: go build/vet/test -short ./internal/... ./cmd/... all
clean; bun run build clean (2900 i18n keys, data-i18n scan clean).
NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.
Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).
NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
this slice per head's brief msg #2393).
t-paliad-313 Slice B
|
|||
| e2969fc358 |
feat(submissions): Composer Slice A — base picker + read-only section list (m/paliad#141)
The first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.
Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
(slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
users, mutations admin-only (handler-enforced, no RLS write paths).
Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
_skeleton.docx. Each section_spec carries the 10-section default
(letterhead, caption, introduction, requests, facts, legal_argument,
evidence, exhibits, closing, signature) with bilingual labels +
bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
composer_meta jsonb (default '{}'). Purely additive; pre-Composer
drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
section_key, order_index, kind ∈ {prose,requests,evidence},
label_de/en, included, content_md_de/en). RLS mirrors
submission_drafts (owner-scoped + can_see_project, four policies).
Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
the firm default base and seeds base_id + section rows in one tx.
Composer wiring is additive — when bases==nil the service stays
v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
on both project-scoped and global drafts accept "base_id".
Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
(hidden until catalog loads); section-list pane above the preview
(hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
paintBasePicker rebuilds <option> list on every paint; onBaseChange
PATCHes base_id and repaints; paintSectionList renders each section
read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
Pre-Composer drafts get the picker (catalog still loads) but the
section pane stays hidden until they pick a base on a new draft.
Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
(pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
seeded 10 default sections on both bases; GetDefaultForCode picks
hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
base_id + 10 section rows in tx with ascending order_index and
bilingual labels populated.
NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.
Build hygiene: go build/vet/test -short clean; bun run build clean
(2900 i18n keys, data-i18n scan clean).
t-paliad-313
|
|||
| 8f1a287549 | Merge: t-paliad-305 — Slice B.2: dual-write to deadline_rules + procedural_events/sequencing_rules/legal_sources (m/paliad#93) | |||
| 38ebccc907 |
feat(services): Slice B.2 dual-write — RuleEditorService writes deadline_rules AND procedural_events / sequencing_rules / legal_sources (t-paliad-305 / m/paliad#93)
Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.
* internal/services/dual_write.go (new) —
- syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
legal_sources + procedural_events + sequencing_rules from the
just-written deadline_rules row. Pure SQL projection, no Go-side
struct mapping. Synthetic-code mint expression is byte-identical
to mig 136 ('null.' || first 8 hex of stripped uuid).
- syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
legacy rule_id back-link onto deadlines.procedural_event_id +
sequencing_rule_id. Handles NULL rule_id naturally (collapses both
new columns to NULL).
- CheckDualWriteDrift(ctx, conn): nine read-only count queries +
integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
log routing.
- StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
that runs CheckDualWriteDrift every `interval` (default 6h) for
the lifetime of ctx. Clean run logs at INFO; drift at WARN with
full report.
* internal/services/rule_editor_service.go —
- Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
published draft AND the cloned-from peer it just archived as a
cascade. The audit_reason already set via setAuditReasonTx
propagates to the new-table writes (same TX, same session).
* internal/services/rule_editor_orphans.go —
- ResolveOrphan calls syncDeadlineDualLinks after UPDATE
paliad.deadlines SET rule_id = $1, so the parallel new columns
follow the legacy back-link.
* internal/services/deadline_service.go —
- DeadlineService.Update calls syncDeadlineDualLinks when
input.RuleSet is true (auto/custom rule swap from t-paliad-258).
* cmd/server/main.go —
- Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
reminder scanner. Inherits bgCtx so the goroutine stops on
SIGTERM. Interval 6h.
* internal/services/dual_write_test.go (new) —
- TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
→ Archive, asserts the new tables mirror at each step. Final
CheckDualWriteDrift returns zero drift.
- TestDualWrite_SyntheticCodeForNullSubmission: rule created with
submission_code=NULL gets a 'null.<8hex>' procedural_events row
matching mig 136's mint expression byte-for-byte.
Scope decisions documented in the commit:
- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
back to legacy" reads as "reads stay on legacy as the safety net
while drift-check validates the new tables". B.3 swaps reads to
new tables only AND stops writing to deadline_rules — that's a
separate slice per the design's §5.2/§5.3 split.
- B.2 does NOT modify submission_drafts, projection_service, the
Fristenrechner calculator, the SubmissionVarsService, the
Schriftsätze list query, or any other reader. They keep reading
deadline_rules unchanged. The new tables are populated in parallel
for B.3's cutover.
- Audit triggers on deadline_rules continue to fire as before. The
new tables have no audit triggers yet (a later slice can add
parallel audit rows once the new tables are authoritative).
- Drift-check uses default 6h interval — short enough that a broken
dual-write surfaces within the same business day, long enough that
the count-COUNTs don't churn the pool. Override via the caller in
cmd/server.
Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
(existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
call twice, safe to re-run after a partial failure.
Build + vet clean. TestMigrations_NoDuplicateSlot passes.
|
|||
| cd5f752a0e |
feat(litigationplanner): scenarios — paliad.scenarios jsonb table + Catalog API + engine adapter (Slice D, t-paliad-306, m/paliad#124 §5)
A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit
|
|||
| c901293c9c |
feat(cicd): Slice A — pre-deploy gate + role-split migration smoke
Adds .gitea/workflows/test.yaml that gates every push on `go build`, `bun run build`, `go vet`, the migration coordination check, and the role-split end-to-end migration smoke. On push to main + green, calls Dokploy's compose.deploy API and polls /health/ready until 200. t-paliad-282 / m/paliad#114. Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md (inventor shift on mai/cronus/inventor-ci-cd-pre). Catches all three of today's outage classes: brunel (~13:20) slot collision -> TestMigrations_NoDuplicateSlot hermes (~16:05) dropped-col refs -> TestBootSmoke mig 129 (~14:56) 42501 ownership -> TestMigrations_EndToEndAsAppRole Snapshot approach. internal/db/testdata/prod-snapshot.sql is a pg_dump of youpc-supabase paliad schema + applied_migrations rows. CI restores this into a fresh `supabase/postgres:15.8.1.060` (same image, same role topology as prod) and runs ApplyMigrations as the `postgres` role (which is NOT a superuser on supabase/postgres, matching prod). Existing migrations are skipped (already in applied_migrations); only NEW migs from the PR run end-to-end. This sidesteps the fresh-DB idempotence debt in some historical migrations (mig 037 missing pg_trgm, mig 051 inner COMMIT) — those are tracked separately and don't block the gate. Sub-changes: - internal/handlers/handlers.go — new /health/ready endpoint distinct from /healthz. /healthz stays liveness (process alive, no DB); /ready is readiness (DB pool pings within 2 s). Returns 503 when svc or pool is nil (DB-less deploys are intentionally not-ready). svc.Pool added to handlers.Services, wired in cmd/server/main.go. - internal/db/migrate_test.go — TestMigrations_NoDuplicateSlot (pure unit, catches brunel) and TestMigrations_EndToEndAsAppRole (snapshot- gated, catches the 42501 class). - cmd/server/main_smoke_test.go — TestBootSmoke now also asserts /health/ready returns 503 with a nil svc. New TestHealthReady_Live asserts 200 against a live pool. - internal/db/migrations/024_rename_department_columns.up.sql and 027_rename_to_partner_units.up.sql — ALTER INDEX / ALTER POLICY exception handlers now catch undefined_object OR undefined_table OR duplicate_object. Old handler only caught undefined_object; Postgres raises undefined_table when source object never existed, and duplicate_object when destination already exists. The expanded handlers make these migrations truly idempotent across all plausible starting states. - Makefile — verify-mig-app, test-frontend, refresh-snapshot targets. refresh-snapshot pg_dumps youpc-supabase prod (needs PALIAD_PROD_DATABASE_URL), strips pg16 \restrict commands for pg15 restore compat, and filters applied_migrations rows to this branch's max on-disk version. - internal/db/testdata/README.md — explains the snapshot's purpose, refresh procedure, and how to verify locally. - docs/cicd-runner-setup-2026-05-25.md — one-time admin steps for registering a Gitea Actions runner on mriver and wiring DOKPLOY_TOKEN as a repo secret. Documents soft-launch plan per m's Q11.4 (keep Dokploy's autoDeploy=true webhook alive for one week, disable after the workflow has gated 5 successful deploys). Build clean. Full go test ./internal/... ./cmd/... green without TEST_DATABASE_URL. With TEST_DATABASE_URL + TEST_APP_DATABASE_URL set to a supabase/postgres scratch + snapshot restored: TestMigrations_NoDuplicateSlot, TestMigrations_EndToEndAsAppRole, TestBootSmoke, TestHealthReady_Live all pass. Live-DB service tests in internal/services/* fail under supabase/postgres 15.8 with a 42P08 parameter-binding error (unrelated to Slice A — tracked as a follow-up). |
|||
| bf60fc1400 |
feat(t-paliad-265): projection engine + HTTP handlers for per-card choices
m/paliad#96 — slice A engine + slice B engine wired together (per m's Q4 bundling decision in §11 of the design doc). Engine (internal/services/fristenrechner.go): - CalcOptions gains PerCardAppellant map, SkipRules set, IncludeCCRFor set. All three keyed by paliad.deadline_rules.submission_code (same key AnchorOverrides uses). - UIDeadline gains AppellantContext (per-decision pick that propagates to descendants via parent_id chain) + ChoicesOffered (passes the jsonb through to the frontend so the caret renders). - Calculate honours all three: * IncludeCCRFor non-empty → append with_ccr to flag set before gate evaluation (v1 simplification documented in CalcOptions comment; correct for single-CCR-entry-point proceedings). * SkipRules suppression via submission_code match AND parent_id cascade (descendants suppress too — one-pass walk in sequence_order). * AppellantContext: each rule with its own per-card pick stamps its UUID; descendants inherit via parent_id lookup; "" = no override. HTTP: - /api/projects/{id}/event-choices GET / PUT / DELETE — full CRUD with visibility gate, audit-logged via paliad.system_audit_log. - POST /api/tools/fristenrechner accepts either projectId (server pulls choices from project_event_choices) OR inline perCardChoices (unbound /tools/verfahrensablauf surface). Inline wins when both. Services wiring: - EventChoiceService instantiated in cmd/server/main.go; threaded into handlers.dbServices.eventChoice. |
|||
| 99c9d89daa |
feat(backups): t-paliad-246 — Backup Mode Slice A (on-demand admin org export)
m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async export) into a new "Backup Mode" surface gated by adminGate. m's calls (all 4 material picks per design §2): - Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only) - Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved - paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally - Scheduler (Slice B): nightly 03:00 UTC, env-tunable Wiring: - mig 123 adds paliad.backups catalog table (kind/status/storage_uri/ size/row_counts/warnings/error/deleted_at + admin-only RLS). - ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for snapshot consistency (design §3.3). - writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx (org snapshot path) work. - BackupRunner orchestrates: catalog INSERT → audit INSERT (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch catalog + audit on success/failure. - ArtifactStore interface + LocalDiskStore impl (defense-in-depth key validation + URI-outside-dir guard). - Sentinel actor for scheduled runs: actor_email='system@paliad', actor_id=NULL — no phantom user in paliad.users. - Admin handlers POST /api/admin/backups/run + GET list/get/download behind adminGate(users, …); /admin/backups page + sidebar entry + bilingual i18n keys. - BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return 503 otherwise (same shape as requireDB). Tests: 8 pure-function tests cover registry shape (no dups, paliadin absent both as sheet name and SQL substring, ref__* sheets unscoped, every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key rejection, URI-traversal rejection, mkdir on construction). go build ./... + go test ./internal/... clean. bun run build clean. Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish) are separate follow-ups per head's instruction. |
|||
| d3aade5aac |
feat(submissions): t-paliad-238 Slice A — dedicated draft editor page
Adds the dedicated Submissions/Schriftsätze editor at
/projects/{id}/submissions/{code}/draft (and …/draft/{draft_id}) per
docs/design-submission-page-2026-05-22.md.
Lawyer picks (or creates) a named draft, edits placeholder variables
in a sticky sidebar, sees a read-only HTML preview of the merged
document body, and exports a .docx with project state + lawyer
overrides resolved. Drafts persist in paliad.submission_drafts
keyed on (project_id, submission_code, user_id, name) with RLS via
can_see_project; updates and deletes additionally gated on owner-only
(Q-E4 owner-scoped pick, m-confirmed).
Resurrected from git history per the design's "no rewrite" plan:
SubmissionVarsService ← commit
|
|||
| d86cac0b53 |
feat(submissions): t-paliad-230 format-only .dotm→.docx convert
m's 2026-05-21 scope reduction of the t-paliad-215 submission generator:
ship a demo that hands the lawyer the firm style template as a clean
.docx. No variable-merge engine, no per-submission template registry,
no fallback chain — the merge slice is deferred to a future task.
Replaces the previous engine (template registry + variable bag +
{{placeholder}} renderer + dual project_events/documents writes) with:
* services.ConvertDotmToDocx — single-function .dotm/.docm/.dotx → .docx
format converter that strips word/vbaProject.bin, word/vbaData.xml,
word/customizations.xml, and word/_rels/vbaProject.bin.rels, rewrites
[Content_Types].xml (demotes the macro/template main type to plain
docx, drops the .bin Default Extension and the macro Overrides), and
rewrites word/_rels/document.xml.rels to drop the vbaProject +
keyMapCustomizations relationships. Idempotent on a plain .docx.
archive/zip + regex stdlib only — no new third-party dependencies.
* handlers/submissions.go — POST /api/projects/{id}/submissions/{code}
/generate fetches the cached HL Patents Style .dotm (via a new
fetchHLPatentsStyleBytes accessor on files.go that shares the same
cache as /files/{slug}), converts, writes one paliad.system_audit_log
row (event_type='submission.generated', metadata={submission_code,
rule_name, filename}), and streams the .docx as an attachment. GET
/api/projects/{id}/submissions still lists filing rules but
has_template is unconditionally true (one universal template).
* Filename per design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}
.docx, with Umlauts ASCII-folded and slashes → underscores.
Drops services/submission_templates.go, services/submission_vars.go,
and the wiring in cmd/server/main.go + handlers/handlers.go that bound
them together. Frontend client switched to POST.
Verified the converter against the real HL Patents Style.dotm (361 KB
input → 243 KB output, 46 parts in output zip):
unzip -tq /tmp/hl-patents-style.converted.docx → No errors
python3 -c "import zipfile, xml.etree.ElementTree as ET; \
z=zipfile.ZipFile('/tmp/hl-patents-style.converted.docx'); \
[ET.fromstring(z.read(p)) for p in z.namelist() if p.endswith('.xml')]"
uv run --with python-docx python3 -c "import docx; \
d=docx.Document('/tmp/hl-patents-style.converted.docx'); \
print(len(d.paragraphs), 'paragraphs', len(d.styles), 'styles')"
→ 236 paragraphs, 168 styles, 1 section
All assertions passed: every Override in [Content_Types].xml resolves
to a real part, every internal Target in document.xml.rels resolves,
zero macro-related residue, and the document body + styles + theme
survive untouched.
go test -run TestBootSmoke ./cmd/server/... clean (route additions
register without conflict on the Go ServeMux).
|
|||
| 6b565be830 |
feat(dashboard): t-paliad-219 Slice C — catalog expansion + firm-wide admin default
Three additions on top of Slice B's edit-mode chrome. **Catalog expansion (2 new widgets, default-hidden — opt-in via picker):** - pinned-projects: surfaces a list of the user's pinned matters via the pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New DashboardService.loadPinnedProjects joins paliad.user_pinned_projects to paliad.projects under the standard visibility predicate, preserves pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects []PinnedProjectRef grows DashboardData; SetPinService wired post-construction to mirror the SetApprovalService pattern. - quick-actions: pure UI affordance with three buttons linking to the existing /projects/new, /deadlines/new, /appointments/new routes. No backend payload, no settings schema. Both default-hidden — m's brief asked for "high-value adds"; injecting new widgets into every user's dashboard unannounced would be loud. Factory test relaxed: visibility now matches catalog.DefaultVisible instead of the previous "all-visible" invariant. **Firm-wide admin default (mig 117 + new service + 4 endpoints):** - paliad.firm_dashboard_default: single-row table (id smallint PK CHECK id=1) with layout_json + updated_by + updated_at. RLS: SELECT authenticated, no INSERT/UPDATE policy (writes go through the service-role connection behind the adminGate). - FirmDashboardDefaultService Get/Set/Clear. Validates against the catalog on Set so an admin can't seed an invalid layout. - DashboardLayoutService.SetFirmDefaultService wires in the firm source. Both GetOrSeed and ResetToDefault now prefer the firm default over the code-resident FactoryDefaultLayout when one is set. Nil-safe — empty firm row falls back to the factory layout, transient DB errors fall back too (a blip can't strand a user without a dashboard). - HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin- gated). POST /api/me/dashboard-layout/promote: admin convenience — reads the admin's own current layout and stashes it as the firm default (saves the JSON-editor step; admins edit via /dashboard's normal editor, then click Promote). **Frontend (Slice B's edit-mode footer grew an admin button):** - "Als Firmen-Standard speichern" button in the edit footer; hidden via CSS-inline until syncPromoteButtonVisibility unhides for global_admin. Confirm() → POST /promote → toast. - The existing "Auf Standard zurücksetzen" copy stays the same — the semantics now "firm default if set, else factory", which is the desired surface: users see one canonical "Standard" link. i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*, dashboard.edit.promote*). i18n-keys.ts regenerated by build. m/paliad#46. go build ./... clean; go vet ./... clean go test ./internal/... clean (Slice C catalog test + factory-default test relaxation; FirmDashboardDefault round-trip tests gated on TEST_DATABASE_URL) Migration 117 dry-run: PASS (other dry-run failures are pre-existing local-DB collisions on origin/main; mig 117 itself clean) bun run build clean: dashboard.html carries new section markup + admin button; dashboard.js bundles renderPinnedProjects + promote handler + all new i18n keys |
|||
| c3cd51eb85 |
feat(checklists): t-paliad-225 Slice B backend — explicit sharing + admin promotion
m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.
Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
recipient via xor-check: recipient_kind in {user, office,
partner_unit, project} with exactly one matching recipient_* column
populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
share branch uses inline ltree walk over projects.path because
can_see_project reads auth.uid() (NULL on service-role connection,
same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
set; accepts the matching kind/recipient pair
Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
required FK target, friendly 409 on partial-unique-index conflict),
Revoke (owner or global_admin), ListGrants (owner or global_admin;
enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
+ promoted_at/by + audit), Demote (global_admin → target visibility,
default 'firm', clears promoted_at/by; rejects demote of non-global
rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
'shared' as an author-set value; 'global' stays admin-only
Endpoints:
- GET /api/checklists/templates/{slug}/shares — list grants (owner/admin)
- POST /api/checklists/templates/{slug}/shares — grant
- DELETE /api/checklists/shares/{id} — revoke
- POST /api/admin/checklists/{slug}/promote — promote to global
- POST /api/admin/checklists/{slug}/demote — demote (body.target default 'firm')
Audit (paliad.system_audit_log):
- checklist.shared — recipient_kind + recipient_id in metadata
- checklist.unshared — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted — target_visibility
Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.
Hotfix-merge note: head shipped
|
|||
| a4e2f3526d |
feat(checklists): t-paliad-225 Slice A backend — user-authored templates
m/paliad#61 Slice A. Introduces paliad.checklists (mig 114) as the DB-backed companion to the static Go catalog. ChecklistCatalogService unifies both sources at read time; ChecklistTemplateService handles authoring CRUD + visibility toggle (private↔firm; Slice B opens 'shared' and 'global'). Schema (mig 114, idempotent): - paliad.checklists (uuid, slug UNIQUE, owner_id FK, title/description /regime/court/reference/deadline/lang, body jsonb, visibility CHECK ('private','shared','firm','global'), promoted_at/_by, timestamps) - paliad.can_see_checklist(uuid, uuid) STABLE SECURITY DEFINER — owner OR firm/global. Slice B extends with the explicit-share branch. - RLS: select via can_see_checklist; insert owner=self; update/delete owner OR global_admin - ALTER paliad.checklist_instances ADD COLUMN template_snapshot jsonb (snapshot semantics so per-Akte instances stay decoupled from subsequent template edits) Services: - ChecklistCatalogService — ListVisible, Find, SnapshotBody, IsStaticSlug. Reapplies visibility application-side (service-role bypasses RLS, per visibility.go pattern). Static-slug map computed once at boot for collision detection. - ChecklistTemplateService — Create (auto-generates u-<slug>-<hex> with retry), Update (changed_fields[] in audit), SetVisibility, Delete, ListOwnedBy, GetBySlug. Owner-or-global_admin gate. - SystemAuditLogService.WriteChecklistEvent — thin helper writing into paliad.system_audit_log with scope='org'. - ChecklistInstanceService.Create now captures template_snapshot via the catalog; GetByID returns it inline so the frontend can render the captured body even after the upstream template is mutated. Endpoints (all owner-gated where mutating): - GET /api/checklists — merged catalog (static + DB visible) - GET /api/checklists/{slug} — single template; static-first lookup - GET /api/checklists/templates/mine — caller's authored templates - POST /api/checklists/templates — create - PATCH /api/checklists/templates/{slug} — edit - PATCH /api/checklists/templates/{slug}/visibility — private↔firm - DELETE /api/checklists/templates/{slug} — delete - GET /checklists/new, /checklists/{slug}/edit — author wizard pages Tests: pure-helper unit tests cover slugifyTitle (umlaut → ae/oe/ue/ss normalisation + clamp), regime/lang/visibility validation, body-shape enforcement, static-slug detection, predicate shape, clamp. |
|||
| 3d3a4fa36d |
feat(team-admin): t-paliad-223 Slice B — Add User via Supabase Admin API
#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside "Onboard existing" and "Invite colleague". Creates both auth.users (via Supabase Admin API) and paliad.users in one click; new user is visible in dropdowns immediately and receives a paliad-branded magic-link email. - internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping. - internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route). - internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort). - internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB). - internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars). - internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject. - internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other). - internal/handlers/handlers.go: route registered behind adminGate. - cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active. - frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on). - frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur. - i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN. Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head. go build && go test -short ./internal/... + bun run build all green. |
|||
| 15bcba5d7c |
feat(dashboard): t-paliad-219 Slice A3 — widen windows + add InboxSummary
Two changes to DashboardService for the configurable dashboard:
1) Widen upcoming windows from 7d/LIMIT 10 → 60d/LIMIT 40 for both
loadUpcomingDeadlines and loadUpcomingAppointments. Per design §18
Note B, the per-widget horizon dropdown (7/14/30/60 days) filters
client-side from a single payload — server-side widening preserves
the Q4 "one big payload" pick without forcing per-widget endpoints.
Existing tests pass: the dashboard CTE bucket math is unchanged and
the wider rows-list is a superset of what /api/dashboard returned
before.
2) Add InboxSummary { pending_count, top: []InboxEntry } to DashboardData
for the new inbox-approvals widget (Q3 expansion). Powered by
ApprovalService.PendingCountForUser + ListPendingForApprover with
Limit=InboxTopCap (10). InboxEntry is the minimum needed to render
a clickable preview line: request id, entity_type/title, project,
requester, requested_at.
ApprovalService is wired post-construction via
DashboardService.SetApprovalService to avoid a circular constructor
dependency. When unwired (knowledge-platform-only deployments,
tests), loadInboxSummary is a no-op and the widget renders its
empty state.
3 new pure-function tests: nil-approvals no-op, SetApprovalService
wiring, InboxTopCap sanity.
go build + go vet + go test ./internal/... -short all clean.
|
|||
| 48f78a713b |
feat(dashboard): t-paliad-219 Slice A2 — HTTP handlers + service wiring
Four endpoints for the per-user dashboard layout: - GET /api/me/dashboard-layout (auto-seeds factory on first call) - PUT /api/me/dashboard-layout (validates against catalog) - POST /api/me/dashboard-layout/reset (overwrites with factory default) - GET /api/dashboard-widget-catalog (catalog metadata for the picker) Catalog endpoint is DB-independent by design — knowledge-platform-only deployments (no DATABASE_URL) still surface the widget metadata. The layout endpoints 503 when the service is unwired, matching the pattern established by handleListCardLayouts / handleListPinnedProjects. Wired through services.Services → handlers.dbServices via the DashboardLayout field. main.go gains a single NewDashboardLayoutService call next to NewCardLayoutService. ErrInvalidInput from the service maps to 400; everything else flows through writeServiceError for the existing 500/503 fallthrough. go build + go vet + go test ./internal/services/ -short all clean. |
|||
| 694c7a53ad |
feat(caldav): Slice 2a backend cut-over — bindings-driven sync (t-paliad-212)
Cuts the CalDAVService sync engine over from the Phase F scalar calendar_path to the binding-row model introduced in Slice 1 (mig 101). Invisible-but-shippable: existing Phase F users keep their backfilled all_visible binding, new users hitting the legacy PUT /api/caldav-config get an auto-created all_visible binding so the "configure → it just works" UX survives. Slice 2b adds the picker UI and write APIs on top. Schema (mig 107) - paliad.caldav_sync_log.binding_id (nullable, FK ON DELETE SET NULL so audit history survives binding deletes). - Per-binding index for the read path. - Idempotent (column-exists DO block) + assertion. Services - CalendarBindingService: ListForUser, ListEnabled, ListAllEnabled, Get, Create, Update, Delete, SetSyncStatus. Mirrors the table CHECK constraints client-side so the API returns useful 400s. - AppointmentTargetService: UpsertAfterPush, FindByUIDAndBinding, ListForBinding, DeleteByAppointmentAndBinding, StaleForBinding. Replaces SetCalDAVMeta as the authoritative source of per-target state; legacy scalar columns still written for back-compat. - AppointmentService.ForBinding: scope filter implementing all_visible, personal_only, project. Hierarchy scopes (client/litigation/patent/case) return ErrUnsupportedScope — Slice 3 wires them via the existing path-based descendant predicate. Sync engine rewrite - CalDAVService.Start iterates ListAllEnabled to discover users with at least one enabled binding. - runSyncOnce loops bindings, writes one caldav_sync_log row per (user, binding) tick, rolls the worst-case error up onto user_caldav_config.last_sync_error so /api/caldav-config still shows aggregate status. - pushBinding pushes the ForBinding() slice + cleans up stale-target rows (project unshared, scope PATCHed). - pullBinding swaps the N×GET pattern for REPORT calendar-multiget (RFC 4791 §7.9; chunked at 100 hrefs to stay inside provider rate limits) and reconciles via per-target etag comparison. - Hooks (OnAppointmentCreated/Updated/Deleted) fan out across the user's matching bindings using appointmentInBinding() — best effort per binding, same 30s timeout as Phase F. - SaveConfig auto-creates an all_visible binding on first-time configure so Phase F "configure → events appear" survives the cut-over. CalDAV client - New ReportMultiget verb implementing RFC 4791 §7.9 calendar-multiget. Chunked at multigetMaxHrefs=100 to fit Google Calendar's per-request cap. HTTP API - GET /api/caldav-bindings — read-only list of the authenticated user's bindings. Slice 2b adds POST/PATCH/DELETE. Verification - BEGIN..ROLLBACK against live Supabase (PG 15.8): mig 107 applies cleanly + the synthetic two-binding scenario lands the project appointment in both bindings while keeping the personal one in master only; cascade on appointment-delete drops targets; cascade on binding-delete drops targets AND sets sync_log.binding_id NULL. - go build ./..., go test ./internal/..., bun run build all clean. Backwards-compat - paliad.appointments.caldav_uid / caldav_etag still written in pushBinding so legacy readers see fresh values. Slice 4 drops them after telemetry confirms no path still reads them. |
|||
| 2c94420a4b |
feat(submissions): t-paliad-215 Slice 1 — HTTP layer + wiring
Two endpoints under /api/projects/{id}/:
GET /submissions
Lists the project's filing-type rules (event_type='filing',
lifecycle_state='published') for the project's proceeding,
each annotated with has_template via the registry's cheap
SHA-only probe. Powers the SubmissionsPanel.
GET /submissions/{code}/generate
Renders the .docx and streams it back as an attachment with
Content-Disposition: attachment; filename="…". Writes three
audit records: paliad.system_audit_log (event_type=
'submission.generated'), paliad.project_events (event_type=
'submission_generated', surfaces in Verlauf / SmartTimeline),
and paliad.documents (doc_type='generated_submission',
file_path NULL — bytes are regenerable from inputs per m's
Q3 pick, no server-side binary). All three writes use a 10s
background context so the user still gets the download if
audit insertion races a slow DB.
File naming follows §7 of the design:
{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx with locale-
aware rule.name and slash→underscore sanitisation on
case_number. Empty case_number falls back to an 8-hex-char id from
the project UUID.
Visibility: ProjectService.GetByID gates every request; 404 (not
403) on no-access to avoid project enumeration. No profession floor
— matches every other write surface in paliad.
Wired into handlers.Services + dbServices + cmd/server/main.go.
Singletons constructed once at boot; no per-request allocation. No
migration needed — paliad.documents has no CHECK on doc_type, so
'generated_submission' is purely additive.
|
|||
| 28c7215458 |
feat(export): t-paliad-214 Slice 1 backend — personal sync export endpoint + xlsx/json/csv writer
Adds GET /api/me/export streaming a deterministic .zip bundle of the caller's RLS-visible projection (per design §2.3): projects, deadlines, appointments, parties, notes, documents (metadata), audit events, approval requests, checklist instances + personal sidecars (me row, caldav config without ciphertext, views, pins, card layouts, paliadin turns) + reference data (proceeding_types, event_types, deadline_rules, courts, countries, holidays …) + restricted users_referenced sheet. Bundle shape: paliad-export.xlsx + paliad-export.json + per-sheet CSVs (UTF-8 BOM, RFC 4180) + README.txt + __meta.json. Outer zip is byte-deterministic — sorted file list, fixed Modified time on every entry, sorted JSON keys. Two runs at same row-state → identical bytes. ExportService.WritePersonal owns the SQL recipe + column discovery + PII deny-regex (?i)secret|token|password|api[_-]?key|private[_-]?key + per-sheet DropColumns belt-and-braces (e.g. user_caldav_config .password_encrypted explicitly dropped on top of the regex). Audit row written to paliad.system_audit_log before the run, patched with row_counts + file_size_bytes after. Migration 102 creates paliad.system_audit_log (generic event_type + actor_id/email + scope + scope_root + metadata jsonb). Idempotent CREATE TABLE IF NOT EXISTS + indexes; RLS enabled with self-read + admin-read policies. AuditService.ListEntries gains a 6th UNION branch so the new table surfaces on /admin/audit-log. excelize/v2 added to go.mod for xlsx generation. Pure-function tests pin formatCellValue value-coercion, PII regex, CSV quoting + BOM + umlaut survival, JSON shape, meta key order stability, filename slugify, and byte-determinism of the bundle assembly. Design: docs/design-paliad-data-export-2026-05-19.md §7 Slice 1. |
|||
| edc81bbbc2 |
feat(t-paliad-194): AichatPaliadinService + PALIADIN_BACKEND=aichat env gate (m/paliad#38 Phase B)
Adds the Phase B paliad-side migration: a thin HTTP client of the centralized aichat backend shipped in m/mAi#207 Phase A (darwin's mai/darwin/issue-207-aichat branch). Implements the same services.Paliadin interface as LocalPaliadinService / RemotePaliadinService — handler plumbing is unchanged, the cutover is a single env-var flip. internal/services/aichat_paliadin.go (~530 LoC): - POST /chat/turn + POST /chat/reset + GET /chat/health via the aichat JSON envelope (mirrors m/mAi internal/aichat/api/types.go verbatim; no module import to keep paliad self-contained). - Per-turn HS256 JWT mint (uses paliadin_jwt.go from the prior commit) when SUPABASE_JWT_SECRET is configured. Aichat owns file write + cleanup; we just sign and ship. - Service-wide health-gate cache (10 s success window, no failure cache — failures re-probe so recovery surfaces immediately). - Per-user-window primer cache. Pulls up to MaxPrimerTurns prior exchanges from paliad.paliadin_turns and ships them in TurnRequest. Primer so a pane respawn (pane_spawned=true in response) doesn't strand the user with a cold claude. Cleared on ResetSession + pane_spawned response. - Username from email_localpart per m's §13 Q2 pick (sanitized inside aichat). Nil-DB fallback: "user-<uuid8>". - Maps aichat's typed wire errors (auth_failed, persona_unknown, mriver_unreachable, bootstrap_failed, timeout, shim_error) onto paliad's existing audit-row codes — preserves the German i18n table in paliadin.ts unchanged (no new strings needed per design §11). cmd/server/main.go: - PALIADIN_BACKEND env: "aichat" → AichatPaliadinService, anything else → existing remote/local/disabled tree. Default = legacy, so every existing deploy is byte-identical until flipped. - buildAichatPaliadinConfig validates AICHAT_URL + AICHAT_TOKEN at boot; AICHAT_PERSONA defaults to "paliadin". JWT secret threaded in so per-user RLS is on by default. Tests cover constructor defaults, health-gate caching + retry + expiry, ResetSession wiring, error-envelope decoding + classifier, HTTP-layer auth/JSON wiring via a roundTripper, JWT mint integration, TurnContext → meta packing, and the env-gate helper. go test ./... green. NOT self-merged — head owns the merge per task instructions. |
|||
| 7decc5095f |
feat(t-paliad-191): admin rule-editor HTTP API
Phase 3 Slice 11a admin endpoints under /admin/api/rules, all
gated through auth.RequireAdminFunc:
GET /admin/api/rules — paginated list with filters
GET /admin/api/rules/{id} — full row
POST /admin/api/rules — create draft
PATCH /admin/api/rules/{id} — update draft only
POST /admin/api/rules/{id}/clone-as-draft
POST /admin/api/rules/{id}/publish
POST /admin/api/rules/{id}/archive
POST /admin/api/rules/{id}/restore
GET /admin/api/rules/{id}/audit — paginated audit log
GET /admin/api/rules/{id}/preview — preview-on-trigger-date
GET /admin/api/rules/export-migrations — SQL blob for the
migration-export flow
Every write endpoint takes a `reason` body field; missing reason →
HTTP 400 (ErrAuditReasonRequired surfaced by the service). The
service writes the reason into paliad.audit_reason in the same tx
as the UPDATE so mig 079's trigger captures it.
writeRuleEditorError maps service-level typed errors to HTTP
statuses (404 for ErrRuleNotFound, 409 for ErrInvalidLifecycleState
+ ErrCyclicSpawn, 400 for ErrAuditReasonRequired + ErrInvalidInput).
dbServices gains a ruleEditor field; Services.RuleEditor in the
public bundle gets wired from main.go via NewRuleEditorService.
Route ordering: export-migrations is registered BEFORE the
{id}-shaped routes so the static path doesn't get captured by the
{id} placeholder. (Go 1.22+'s ServeMux requires the explicit
registration order for shadowing-resolution.)
Frontend (Slice 11b) will hire a new coder to surface the API in
an admin UI. Slice 11a ships the backend in isolation so the editor
can drive the lifecycle via curl / mai instructions today.
|
|||
| 7bfec310a0 |
feat(t-paliad-187): POST /api/tools/event-trigger handler + wiring
Phase 3 Slice 6 handler. Decodes JSON body (eventTypeId, conceptId,
triggerDate, flags, courtId, perspective), validates required
fields (triggerDate + at least one identifier), parses UUIDs (400
on malformed), delegates to EventTriggerService.Trigger, surfaces
ErrInvalidInput as 400 with the service's German user-facing
message.
Wiring:
- dbServices gains an eventTrigger pointer (handlers package
internal type) wired from handlers.Services.EventTrigger.
- handlers.Services.EventTrigger is the new exported field; the
bundle constructor in main.go fills it from
NewEventTriggerService(pool, rules, holidays, courts).
- Route registered as POST /api/tools/event-trigger on the
protected mux, sibling to the existing /api/tools/fristenrechner
and /api/tools/event-deadlines endpoints.
Returns 503 when DATABASE_URL is unset (matches every other
calculator endpoint's behaviour). Returns same JSON shape as
/api/tools/fristenrechner so the frontend can render with the
existing timeline renderer.
|
|||
| 8a814e3442 |
refactor(t-paliad-184): EventDeadlineService.Calculate delegates
Phase 3 Slice 3 service-side rewire. EventDeadlineService.Calculate
now:
1. Looks up trigger event metadata (unchanged — the legacy response
shape still carries TriggerEvent + TriggerDate at the top level).
2. SELECTs source event_deadlines rows for the trigger to recover
(id, duration, alt_*, combine_op, notes_en) — the unified
UIResponse drops those fields. SELECT is still allowed by the
mig 086 read-only trigger; only writes are blocked.
3. Delegates the rule SELECT + math to FristenrechnerService.Calculate
with TriggerEventIDFilter set.
4. Merges the unified result with the source rows (join by Name =
title_de) to produce the legacy EventDeadlineResult shape with
ID, ruleCodes, isComposite, compositeNote intact.
5. Loads rule_codes from event_deadline_rule_codes (also still
readable) by source.id.
Public signature unchanged — /api/tools/event-deadlines callers see
no diff. The legacy applyDuration / addWorkingDays helpers stay on
EventDeadlineService for the pure-Go unit tests + the composite-note
leg-pick that the unified UIDeadline doesn't expose.
main.go wiring: NewEventDeadlineService gains the FristenrechnerService
dependency.
|
|||
|
|
85d7dd497c |
feat(t-paliad-173): SmartTimeline Slice 2 backend — projection + anchor + skip + sequence guard
Slice 2 of the SmartTimeline (docs/design-smart-timeline-2026-05-08.md
§6 + §9 + §10) bundled with m/paliad#31's layered requirements:
Migration 076:
- appointments.deadline_rule_id nullable FK to deadline_rules + partial idx
- deadlines.source CHECK widened to include 'anchor' (alongside existing
'manual','fristenrechner','rule','import').
ProjectionService (extended):
- Wires FristenrechnerService + DeadlineRuleService.
- For() now emits Kind="projected" rows for any rule lacking a matching
paliad.deadlines.rule_id / appointments.deadline_rule_id row, with
Status in {predicted | predicted_overdue | court_set}.
- Lookahead cap (default 7, override via ?lookahead=N, max 50): future
predicted rows beyond N are dropped; predicted_overdue + court_set
rows are exempt from the cap (#31 layer 1).
- Dependency annotations DependsOnRuleCode/Date/Name on every row that
carries a DeadlineRuleID, walked from the rule's parent_id chain
(#31 layer 2). Date prefers actuals over projections.
- AnchorOverrides built from completed deadlines (completed_at /
status='completed') + appointments tied via deadline_rule_id.
- triggerDate derives from the proceeding's root rule's anchor when
present, else today() as placeholder.
Anchor write path (POST /api/projects/{id}/timeline/anchor):
- Sequence guard: if rule.parent_id has no anchored actual, return
409 predecessor_missing with the missing rule's code/name DE+EN +
pre-formatted bilingual messages so the frontend can render an
inline error with a "Stattdessen <predecessor> erfassen" link
(#31 layer 3, no confirm-and-write override in v1).
- kind dispatch: rules with event_type IN ('hearing','decision','order')
write paliad.appointments with deadline_rule_id; everything else
writes paliad.deadlines with source='anchor', status='completed',
completed_at=actual_date.
- Idempotent: existing (project_id, rule_id) row PATCHes instead of
inserting (race-safe per design §13).
Skip write path (POST /api/projects/{id}/timeline/skip):
- Writes paliad.project_events with event_type='rule_skipped' +
metadata.rule_code; subsequent reads drop the matching projected
row from the cascade (§6.4).
Handlers expose projection meta via X-Projection-{Has,Total,Shown,Overdue,Lookahead}
headers so the wire shape stays []TimelineEvent (frozen since Slice 1).
|
||
|
|
afd3aab2b2 |
feat(t-paliad-171): SmartTimeline backend skeleton — ProjectionService + /timeline endpoint
Slice 1 of the SmartTimeline (Verlauf-tab redesign). Adds a new service
layer + two HTTP endpoints; no projection logic yet (Slice 2). The wire
shape (TimelineEvent) is frozen so future slices add Kind="projected"
rows additively without breaking the frontend consumer.
ProjectionService.For composes three actuals streams for one project:
- paliad.deadlines → Kind="deadline"
- paliad.appointments → Kind="appointment"
- paliad.project_events with
timeline_kind IS NOT NULL → Kind="milestone"
Visibility goes through the existing inline mirror of
paliad.can_see_project on each underlying service — no new RLS surface.
DirectOnly mirrors the existing "Inkl. Unterprojekte" toggle on
/projects/{id}; IncludeAuditFull broadens project_events to the full
audit log behind the upcoming "Audit-Log anzeigen" header toggle.
ProjectionService.RecordCustomMilestone backs POST /timeline/milestone
("Eigener Meilenstein") — the only write path in Slice 1.
Tests: unit (sort order, status mapping, kind tiebreak — runs by default)
plus a live integration test that seeds one project + dl + appt +
milestone and asserts the merge surfaces all three with the right
ordering. Live test gated on TEST_DATABASE_URL per the existing
convention.
Design ref: docs/design-smart-timeline-2026-05-08.md §2.3 + §9.2 + §10.
|
||
|
|
d2790a0461 |
feat(paliadin): reconcile late responses via janitor + chat polling
When Claude writes the response file after the 60 s pollForResponse
window expires (e.g. the tmux pane was busy mid-turn when the message
arrived), the SSE stream has already closed with an error and the
file sits unread on disk forever. The chat shows a permanent timeout
even though the answer exists.
Backend:
- LocalPaliadinService.StartJanitor: scans responseDir every 2 s and
patches rows whose response is still NULL when the file lands.
completeTurnLate stamps error_code='late' so the FE can render a
marker. Guarded with WHERE response IS NULL to never overwrite a
real response if RunTurn races.
- Paliadin.GetTurn(callerID, turnID) on the shared paliadinDB. Same
visibility predicate as ListRecentTurns.
- GET /api/paliadin/turns/{id} — owner-gated; lets the chat UI
discover late-arrived responses without a refresh.
Frontend:
- paliadin-late-poll.ts: shared 3 s / 10 min poller.
- paliadin.ts + paliadin-widget.ts: on SSE error, show
"wartet auf späte Antwort", kick off the poller, swap bubble in
place when response arrives + retroactively persist to history.
- i18n: paliadin.late.waiting + paliadin.late.marker (DE/EN).
- CSS: --late-pending opacity tweak, --late neutral background,
italic-grey "verspätet" tag.
|
||
|
|
97a412498d |
feat(t-paliad-155): real Claude SKILL.md + per-user tmux session
Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.
Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.
Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
session arg + caches per-key; ResetSession derives session from UserID
and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.
Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.
Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.
Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
|
||
|
|
4c47819da8 |
fix(t-paliad-151): base64-decode PALIADIN_SSH_PRIVATE_KEY env var
Dokploy stores compose env vars in a single-line `.env` file, which
silently truncates multi-line values to their first line. Empirically
verified inside the running paliad container: a multi-line PEM
arrived as just `-----BEGIN OPENSSH PRIVATE KEY-----\n` (36 bytes)
and `ssh -i …` failed with `Load key: error in libcrypto`.
decodePaliadinPrivateKey now accepts either:
- raw PEM (multi-line, starts with `-----` and contains a newline) —
used as-is for local-dev convenience
- base64-encoded PEM — decoded into raw PEM. Survives the .env
one-line-per-key round-trip.
Whitespace (spaces / line breaks) inside the base64 blob is stripped
before decoding so an OpenSSH-keygen-helper-style 64-char-wrap is
also accepted.
After deploy, m needs to update the Dokploy PALIADIN_SSH_PRIVATE_KEY
secret to the base64-encoded form:
base64 -w0 < ~/.paliad-staging/paliad-prod-key
…and redeploy. Then sshd's libcrypto loads the key correctly and the
shim's command= path runs.
Refs m/paliad#12
|
||
|
|
0c8a2f1a95 |
feat(t-paliad-151) RemotePaliadinService + main.go env-var routing
Phase B step 2: lands the Paliadin backend that talks to mRiver via ssh + paliadin-shim. Local backend untouched — selection happens in cmd/server/main.go based on PALIADIN_REMOTE_HOST. Files: - internal/services/paliadin_remote.go (new) — RemotePaliadinService + RemotePaliadinConfig, with five SSH knobs (Host/Port/User/KeyPath/ KnownHostsPath). RunTurn does insertTurnRow → healthGate → bootstrap → callShim run-turn → splitTrailer → completeTurn, mirroring the local path's audit-row contract. ResetSession sends shim 'reset'. callShim runs `ssh -F /dev/null -i <key> -p <port> -o … host -- verb args`; ControlMaster intentionally not enabled (design §6.8). - internal/services/paliadin_remote.go also adds DisabledPaliadinService (returns ErrPaliadinDisabled from RunTurn/ResetSession; DB methods inherited from paliadinDB still work) so cmd/server/main.go can wire a non-nil Paliadin even when neither local tmux nor remote SSH is available. - ErrMRiverUnreachable sentinel for the friendly error code. - classifySSHError translates ssh exit 124 / Permission denied / network errors into the audit-row error_code field. - Compile-time conformance: var _ Paliadin = (*Local|*Remote|*Disabled) PaliadinService(nil). cmd/server/main.go switch: PALIADIN_REMOTE_HOST set → NewRemotePaliadinService else: tmux on PATH → NewLocalPaliadinService else: NewDisabledPaliadinService buildPaliadinRemoteConfig materialises PALIADIN_SSH_PRIVATE_KEY + PALIADIN_KNOWN_HOSTS (multi-line Dokploy secrets) into chmod-600/644 tmpfiles at boot. Defaults: SSHUser=m, SSHPort=22022 (bypasses Tailscale SSH on :22, see design §4.5). Fails fast on a configured remote-host without the matching key/known_hosts secrets. Local-tmux mode now requires `tmux` actually be on PATH at boot (exec.LookPath gate); previously the constructor unconditionally returned a service whose RunTurn would fail at runtime with ErrTmuxUnavailable. The handler-level "friendly error" UX is unchanged: DisabledPaliadinService surfaces ErrPaliadinDisabled which the frontend renders the same way. Build green; existing paliadin_test.go still passes (it tests package-level helpers, untouched). Remote-specific tests land in B4. Refs m/paliad#12 |
||
|
|
56a3dc961e |
refactor(t-paliad-151): extract Paliadin interface; rename PaliadinService → LocalPaliadinService
Phase B step 1 of the Tailscale-SSH route to mRiver. Splits the existing local-tmux PoC into a Paliadin interface with two implementations; the remote-SSH backend lands in a follow-up commit (paliadin_remote.go). Surface: - Paliadin interface — RunTurn, ResetSession, ListRecentTurns, Stats, IsOwner. The handler at internal/handlers/paliadin.go now talks to this instead of the concrete struct. - paliadinDB — embedded base type carrying the audit-table I/O (insertTurnRow, completeTurn, markTurnError, markTurnAbandonedOrError) plus the read-side queries (IsOwner, ListRecentTurns, Stats). Both Local and Remote impls inherit these by embedding paliadinDB so the remote path doesn't have to duplicate any DB code. - LocalPaliadinService — the renamed PoC backend. Identical behaviour to the previous PaliadinService; only the type name and method receivers change. Method receivers split: tmux-specific operations (RunTurn, ResetSession, ensurePane, sendToPane, pollForResponse, etc.) stay on *LocalPaliadinService; DB-only operations promote to *paliadinDB. Wiring: - internal/handlers/handlers.go — Paliadin field becomes the interface type; Register() unchanged. - cmd/server/main.go — calls NewLocalPaliadinService instead of NewPaliadinService. The remote-vs-local switch on PALIADIN_REMOTE_HOST lands in B5. Tests in paliadin_test.go all green — they test package-level functions (splitTrailer, countChips, approxTokenCount, sanitiseForTmux, PaliadinOwnerEmail) and don't touch the renamed struct. No behaviour change on the local-tmux path. Refs m/paliad#12 |
||
|
|
4e1d311a9c |
feat(t-paliad-149) PR2 step 1/3: backend — migration 061 + CardLayoutService + CardsPreview
Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
labels.
- RLS owner-only (mirrors paliad.user_views from t-144).
LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}
DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).
CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.
ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.
Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).
Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.
Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
title-row-first invariant, hidden leading allowed, dup-key rejection,
unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
+ idempotent, first Create auto-becomes default, SetDefault clears
prior, Delete refuses active default, Delete non-default works,
duplicate name rejected, Update round-trips layout JSON.
go build / vet / test (short) clean.
Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
|
||
|
|
8412328dec |
feat(t-paliad-149) PR1 step 1/3: backend — migration 060 + PinService + BuildTreeWithOptions
Migration 060 (paliad.user_pinned_projects): per-user, RLS owner-only, ON
DELETE CASCADE on both FKs.
PinService (Pin / Unpin / IsPinned / PinnedSet / ListPinned): visibility-
gates pin (can't pin what you can't see) but not unpin (so users can clean
up after losing access). PinnedSet returns a map for O(1) lookups during
tree stitching.
ProjectService.BuildTreeWithOptions extends BuildTree with chip-driven
filtering. New ProjectTreeNode fields are additive (Pinned,
InheritedVisibility, OpenDeadlinesSubtree, OverdueDeadlinesSubtree,
MatchKind) so the old BuildTree(ctx, userID) call still works for legacy
callers. New options:
Scope: All / Mine / Pinned (Mine + Pinned both expand to path-closure
with InheritedVisibility flag on greyed ancestors)
StatusIn / TypeIn: chip-narrowing whitelists
HasOpenDeadlines: per-node or subtree-aggregated, depending on
IncludeSubtreeCounts
SearchTerm: case-fold contains on title/reference/clientmatter, then
prune to {matches ∪ ancestors ∪ descendants} with match_kind tagged
IncludeSubtreeCounts: post-order DFS sums, O(N)
GET /api/projects/tree gains query params: scope, status, type,
has_open_deadlines, q, subtree_counts. Zero query string preserves
legacy behaviour.
POST/DELETE /api/projects/{id}/pin and GET /api/user-pinned-projects
wired. Service registered in cmd/server/main.go and dbServices.
build + vet clean.
Design: docs/design-projects-page-2026-05-07.md §4.7, §8.1, §8.3.
|
||
|
|
8d714dd95e |
fix(t-paliad-146): gate Paliadin to owner email in code, drop PALIADIN_ENABLED
m's call (2026-05-07 21:52): "remove the export variable, that is bad form. It should be connected only to my account." The PALIADIN_ENABLED env var was a deploy-time toggle: easy to mis-flip, splits prod/dev behaviour, and reads as "could be turned on for anyone." Replaced with a per-request gate in code: services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com" handlers/paliadin.go now gates every entry point through requirePaliadinOwner, which looks up paliad.users.email by the caller's UUID and returns 404 (not 403 — pretend the route doesn't exist) for anyone else. Routes register unconditionally; the gate is in the code, not the deploy. main.go wires PaliadinService whenever DATABASE_URL is set and logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED row and gains an explanatory note about the in-code gate. Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under Admin) now render with display:none, revealed by sidebar.ts after /api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL — same fail-closed pattern the Admin group already uses. Side-effect for ops: paliad.de production now serves the routes too, but only to m, and only successfully if the host has tmux + claude in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a "tmux unavailable" — clear failure mode, not a security concern. One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the constant aligned with migration 023's seed so a future rename of m's account doesn't silently strand the gate. All existing tests pass. |
||
|
|
7b66c4d035 |
feat(t-paliad-146): Paliadin PoC — tmux-Claude in-app AI buddy
Phase 0 of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only laptop scope, gated behind PALIADIN_ENABLED=false on
prod. Lifts the goldi/mVoice tmux-Claude pattern (mVoice/server.py:
250-380) into a Go service: long-lived `claude` pane in a tmux
session, prompts in via `tmux send-keys -l`, responses out via a
per-turn file (/tmp/paliadin/{turn_id}.txt) the system prompt
instructs Claude to write.
What landed
-----------
- migration 058_paliadin_poc — paliad.paliadin_turns audit table
(full prompt + response stored at PoC scope; redaction returns
at production v1 per design §3.3). RLS: user sees own,
global_admin sees all.
- internal/services/paliadin.go — the orchestrator. ensurePane()
finds-or-creates the tagged tmux window, sendToPane sends the
framed [PALIADIN:turn_id] envelope, pollForResponse reads the
per-turn file, splitTrailer parses the [paliadin-meta] block
Claude appends to every reply (used_tools, rows_seen,
classifier_tag).
- internal/services/paliadin_prompt.go — the system prompt sent
once to a fresh Claude pane. Defines the response protocol
(Write-to-file + meta trailer), the action-chip marker syntax,
the visibility-gate rule (paliad.can_see_project required in
every project-scoped query), and 9 SQL recipes covering m's
paliad data + cross-schema youpc case-law lookup.
- internal/handlers/paliadin.go — POST /api/paliadin/turn kicks
off the work in a goroutine and returns an SSE URL; GET
/api/paliadin/stream/{id} relays per-turn channel events
(meta/content/end/error/ping) to EventSource. Routes register
ONLY when PaliadinService is wired — paliadinSvc nil → no
handlers exist, prod surface is clean.
- /admin/paliadin dashboard — global_admin-only. Shows total
turns, last-7-days, median/p90 duration, tool-use rate (the
load-bearing §0.5.7 metric), abandon rate, classifier
histogram, daily sparkline, top prompts, recent turn log.
Powered by PaliadinService.Stats() + ListRecentTurns().
- frontend: paliadin.tsx + client/paliadin.ts (chat panel with
starter prompts, EventSource consumer, typewriter render of
one-shot content blob, citation-chip parser, "Stop" + "New
conversation" buttons, localStorage history); admin-paliadin
pair (read-only stats dashboard).
- Sidebar: Paliadin entry under Übersicht (ICON_SPARKLE);
Paliadin Monitor under Admin.
- 36 i18n keys (DE+EN), CSS for chat panel + dashboard.
- main.go: PaliadinService wires only on PALIADIN_ENABLED=true,
with PALIADIN_TMUX_SESSION + PALIADIN_RESPONSE_DIR overrides.
Logs visibly so the operator can confirm at boot.
- CLAUDE.md: ANTHROPIC_API_KEY row updated (PoC doesn't need it
— Claude CLI uses m's subscription; key reserved for future
production-v1). New rows for the three PALIADIN_* env vars.
Tests
-----
- 7 unit tests on the trailer parser, chip counter, token approx,
and tmux-input sanitiser. All pass. The trailer parser is
load-bearing for monitoring; an unobserved parser bug = silent
dashboard rot.
What's NOT in v1 (stays deferred)
---------------------------------
- The Anthropic API client (production v1, gated on PoC success
per §0.5.7).
- BYO-AI / OpenAI adapter.
- Per-user rate limiting.
- Multi-replica SSE bus.
- Mascot / avatar SVG.
- Persistent threads (history is browser localStorage only).
How to use locally
------------------
$ export PALIADIN_ENABLED=true
$ ./paliad
# browse /paliadin → type a question → answers stream back
# /admin/paliadin shows the monitoring dashboard
Migration: 058 (skips fritz's t-147 on 057). Safe on prod
because PALIADIN_ENABLED defaults to false; the table is created
but no routes touch it until the env var flips.
|
||
|
|
52ee319fd8 |
feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends personalised emails to a filter-narrowed subset of the team. Each recipient gets their own envelope (per-recipient privacy, no shared To: list); From stays on the SMTP infrastructure address with Reply-To set to the human sender so replies route correctly without forging DKIM/SPF. Backend - Migration 057: paliad.email_broadcasts (subject, body, sender_id, template_key, recipient_filter jsonb, recipient_user_ids uuid[], send_report jsonb, sent_at). RLS: senders read own rows, global_admin reads all; inserts must self-attribute. No CHECK-constraint extension to partner_unit_events — broadcasts get their own table per the lock. - BroadcastService (internal/services/broadcast_service.go): validates subject/body/recipient cap (100), enforces project_lead-OR-global_admin, persists audit row, dispatches via 5-deep goroutine pool with 15s per-send timeout. Send report (sent/failed counts + per-recipient errors) is captured back into email_broadcasts.send_report. - markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**, *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped first; only whitelisted tags re-emitted. Script tags and javascript: URLs can't slip through. - Placeholder substitution: {{name}}, {{first_name}}, {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass through unchanged. - mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header on top of the existing multipart/alternative envelope. - TeamService.ListMembershipsIndex: visibility-gated user→project_ids index. Powers the /team project multi-select filter without N round trips per project. - Handlers: POST /api/team/broadcast (gateOnboarded; service enforces authority), GET /api/team/memberships, GET /api/admin/broadcasts (list), GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page). /admin/broadcasts is gateOnboarded (not adminGate) so leads can see their own sends; the service applies the per-row visibility filter. Frontend - /team gains a project multi-select chip dropdown (visible projects loaded from /api/projects, intersected against the memberships index) alongside the existing office and role filters. - "E-Mail an Auswahl (N)" button appears only when canBroadcast() is true (global_admin always; non-admin needs lead-ship on selected projects, or at least one project when no filter is set). Server still re-checks per send. - Compose modal (broadcast.ts): subject + body textarea + optional template dropdown (loads existing email templates and strips Go-template directives) + recipient preview (first 5 + expand) + send. Hard-blocks empty subject/body and N=0. Shows per-send report on success. - /admin/broadcasts viewer: read-only list with click-row-to-expand detail (subject, body, recipient list, send_report counts). Tests - broadcast_service_test.go: placeholder substitution table-driven, Markdown safe-render incl. XSS guards (<script>, javascript: URLs), validation cases (empty subject/body, recipient cap, invalid email), signature rendering DE/EN. - broadcast_service_live_test.go: end-to-end Send + List + Get + visibility rules (lead can send on own project, member cannot, admin sees all, member can't read lead's row). Skips when TEST_DATABASE_URL is unset. i18n: 60 new keys × 2 langs (broadcast modal labels, error messages, recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/ load_error). |
||
|
|
b516201110 |
feat(t-paliad-144 A1): backend substrate + Custom Views API
Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only; no user-visible change in A1. A2 (frontend) lands separately. What's new: - Migration 056: paliad.user_views table with RLS scoped to caller (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE (user_id, slug). No is_system flag — system defaults stay code- resident per Q8 lock-in. - internal/services/filter_spec.go (+test): structured FilterSpec with Sources / Scope / Time / Predicates. Server-side validator rejects unknown sources, duplicate sources, conflicting scope modes, horizon=all without explicit projects (Q26 clamp), and every per-source enum (deadline.status, appointment_types, project_event kinds, approval_request status / viewer_role). - internal/services/render_spec.go (+test): RenderSpec with three shapes (list / cards / calendar — Q4 lock-in 2026-05-07). Per-shape config kept separately so flipping shapes preserves tweaks. Validator over column / sort / density / group_by / default_view enums. - internal/services/system_views.go (+test): code-resident SystemView definitions for dashboard / agenda / events / inbox / inbox-mine. Reserved-slug list (Q23) prevents user-views from colliding with top-level URLs. Case-folded matching. - internal/services/view_service.go: extends EventService with RunSpec — runs a FilterSpec across all four substrate sources (deadline + appointment + project_event + approval_request) and merges into []ViewRow sorted by event_date. ViewRow is a discriminated projection (kind + common header + per-source Detail json.RawMessage). Q17 fail-open attribution: returns inaccessible_project_ids for explicit-scope queries where the caller can't see some IDs. - internal/services/user_view_service.go (+test): CRUD on paliad.user_views — Create (server-assigns sort_order MAX+1 in tx), GetBySlug, GetByID, Update (partial), Delete, Touch (last_used_at), MostRecent. Reserved-slug + slug-format validators on every write. - internal/handlers/views.go: nine HTTP handlers wiring the endpoints (GET/POST/PATCH/DELETE /api/user-views/..., POST /api/user-views/{id}/touch, POST /api/views/run, POST /api/views/{slug}/run, GET /api/views/system). - main.go + handlers.go + projects.go: wire UserViewService into the bundle; conditional route registration when both UserView + Event services are present. Pure-Go tests (no DB): 32 cases pass — filter spec validators, render spec validators, system view registry, reserved slugs. Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases covering create / list / get / uniqueness / update / delete / touch / most-recent / reserved-slug / bad-slug / empty-name / invalid-spec. Coexists with t-139 (in-flight on noether's other branch) and t-138 (shipped) without coordination commits — RunSpec uses the existing visibility predicate that t-139's migration 055 will extend with derivation. Approval-request source delegates to ApprovalService.ListPendingForApprover / ListSubmittedByUser (both already extended for derived_peer authority in t-139 Phase 3). Files: 15 changed, 3134 insertions. Build clean. Tests green. |
||
|
|
544bb63684 |
feat(t-paliad-139): Phase 2 — partner-unit derivation schema + Team-tab subsections
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
|
||
|
|
10b3426086 |
feat(t-paliad-138): wire ApprovalService into deadline + appointment paths
Commit 3 of 8. The 4-eye gate now actually fires. With migration 054 applied and an approval_policies row configured for a project, the relevant Create/Update/Complete/Delete on a Deadline or Appointment flips approval_status='pending' and emits a *_approval_requested audit event. Without policies, behaviour is unchanged. Backend changes: - models.Deadline + models.Appointment gain approval_status, pending_request_id, approved_by, approved_at; appointments also gain completed_at (for the appointment:complete lifecycle event). - deadlineColumns + appointmentColumns include the new fields so every existing read path hydrates them via sqlx StructScan with no per-call-site changes. - DeadlineService gains SetApprovalService (nil-tolerant). Wired in main.go after the bundle is built. - AppointmentService gains the same hook + dependency. Lifecycle wiring: - DeadlineService.Create / Update / Complete / Delete each consult the approval gate. Update only triggers approval when a date-bearing field actually changes (Q4 allowlist: due_date, original_due_date, warning_date). Cosmetic edits (title, description, notes, rule_code, event_type_ids, status, completed_at via reopen) bypass. - AppointmentService.Create / Update / Delete same shape. Update only gates on start_at / end_at changes. Personal appointments (project_id IS NULL) never gate (no project policy to consult). - Delete is the one stage-then-write exception: the row stays alive with approval_status='pending' until the approver hard-deletes (approve) or restores it (reject). On no-policy projects, delete is immediate as before. - Concurrent-pending guard: any mutation on a row whose approval_status='pending' returns ErrConcurrentPending. The user must wait for the in-flight request to settle (or revoke if they're the requester). Pre_image capture: the date-bearing fields that are about to change are snapshotted into the approval_requests.pre_image jsonb at submit time. Reject/Revoke applies them back over the row to revert. |
||
|
|
4ebbf2c1af |
feat(t-paliad-138): ApprovalService core + tests
Commit 2 of 8 — the workflow engine for the 4-Augen-Prüfung. Wires the
service into the handlers.Services bundle so commit 3 can call into
SubmitCreate/Update/Complete/Delete from DeadlineService and
AppointmentService.
Public surface:
- Submit{Create,Update,Complete,Delete} — invoked by Deadline /
AppointmentService inside their existing tx. Looks up policy,
runs the deadlock check, inserts paliad.approval_requests, marks
the entity pending, emits the *_approval_requested project_events
audit row.
- Approve / Reject / Revoke — top-level operations (own tx). Approve
finalises the lifecycle (clears pending markers + sets approved_by
for non-delete; hard-deletes for delete). Reject / Revoke revert
the entity from pre_image (delete a pending-create, restore date
fields, NULL completed_at).
- ListPendingForApprover / ListSubmittedByUser / GetRequest /
PendingCountForUser — read paths the inbox + bell will hit in
commit 5.
- ListPolicies / UpsertPolicy / DeletePolicy — CRUD for the
authoring page in commit 4.
Self-approval is blocked at three layers:
1. canApprove() returns ErrSelfApproval when caller == requester.
2. The DB CHECK constraint approval_requests_no_self_approval.
3. The deadlock check excludes the requester from the pool.
Strict-ladder helper levelOf(role) mirrors the SQL function added in
migration 054. Path-walk authorization: ancestors with eligible roles
qualify for descendant requests (matches the visibility predicate).
Tests:
- Pure-Go: levelOf strict-ladder semantics, IsValidRequiredRole,
approvalEventType. All pass under `go test`.
- Live-DB (TEST_DATABASE_URL): no-policy noop; submit→approve cycle;
reject-create deletes; reject-update restores pre_image;
no-qualified-approver fail; revoke flow; policy CRUD roundtrip.
Skipped when TEST_DATABASE_URL is unset, mirroring the existing
audit_service_test pattern.
No call sites in DeadlineService / AppointmentService yet — that's
commit 3. Paliad continues to behave identically until that lands.
|
||
|
|
d72990ad1b |
feat(t-paliad-122): country+regime aware HolidayService + CourtService
Holiday struct gains Country (ISO-3166) + Regime ('UPC' | 'EPO' | "")
fields. AppliesTo(country, regime) is the matching rule the new lookup
methods filter through: a row matches when its Country equals the
court's country OR its Regime equals the court's regime. UPC LD München
(DE+UPC) sees DE federal + UPC vacations; LG München (DE+"") sees only
DE federal; UPC LD Paris (FR+UPC) sees FR + UPC. germanFederalHolidays
fallback now country-tagged 'DE' so the per-country filter applies it
only to DE-jurisdictional callers.
Public service methods (IsHoliday, IsNonWorkingDay, AdjustForNonWorking
Days, AdjustForNonWorkingDaysWithReason, findVacationBlock) all take
(country, regime). Cache stays year-keyed — single DB hit per year, all
courts touching that year share it.
New CourtService loads paliad.courts once + answers Lookup(id),
CountryRegime(id, defaultCountry, defaultRegime), All(), ByCourtType(t).
FristenrechnerService.CalcOptions / CalcRuleParams gain CourtID;
EventDeadlineService.Calculate gains courtID. When courtID is empty,
DefaultsForJurisdiction maps the proceeding's existing jurisdiction
column to a sensible (country, regime) default — UPC proceedings get
(DE, UPC), everything else gets DE-only — preserving today's behaviour
for callers that don't yet send a court.
Tests: new TestAppliesTo_CountryRegimeFilter + TestAppliesTo_Rules
cover the cross-product of (DE court / UPC LD München / UPC LD Paris /
LG München) × (DE federal / UPC vacation / FR holiday). Existing tests
threaded through with ('DE', 'UPC') to preserve behaviour they were
written to lock.
|
||
|
|
f40b652d01 |
Reapply "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"
This reverts commit
|