0ac26fe0ee08c4ff632e191e01a1bb44973d28b9
247 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| cdd3747c2b | Merge: t-paliad-250 — Browse-a-proceeding side+appellant selectors + 'appealable decision' trigger label (m/paliad#81) | |||
| 02255c4234 |
mAi: #81 - verfahrensablauf side+appellant selectors + UPC Appeal trigger label
Concerns A + B + C from m/paliad#81:
A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
(Kläger/Beklagter/Beide) and an appellant selector. The side selector
swaps which column labels which user-side; the appellant selector
collapses party='both' rules into the appellant's column (no mirror)
so role-swap proceedings (Appeal, etc.) stop showing every row
twice in the timeline. Both selectors are URL-driven (?side= +
?appellant=) and re-render without a backend round-trip.
The appellant row hides itself for proceedings without an appellant
axis (first-instance Inf/Rev/Opp) via a small allowlist.
B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
/ "Appealable Decision" instead of falling back to the proceeding
name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
trigger_event_label_{de,en} column on paliad.proceeding_types (mig
121); the frontend prefers it over the proceedingName fallback that
fires when no rule has IsRootEvent=true. No new deadline rules, no
slug changes (hard rule from the issue).
C. Parameter contract for the column projection is unified in
bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
helper extracted from renderColumnsBody so the routing behaviour
stays unit-testable without a DOM. Tests cover the default mirror,
appellant-collapse for both sides, side-swap of column ownership,
the combined case, and row alignment by dueDate.
Verification
- go build ./... clean
- go test ./... all green
- bun run build (frontend) clean
- bun test (frontend/src) 110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
carries the curated trigger label pair.
Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
|
|||
| 5df87f4129 |
fix(submissions): t-paliad-253 — /generate runs the merge engine
The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.
The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.
Fix:
- `internal/services/submission_draft_service.go` — add
`RenderProjectSubmission(ctx, userID, projectID, submissionCode,
templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
no-saved-draft path. Returns the merged bytes plus the resolved
SubmissionVarsResult (rule, project, user, lang) so the handler can
derive filename + audit metadata without a second DB round-trip.
- `internal/handlers/submissions.go` — rewrite
`handleGenerateProjectSubmission` to resolve the template via
`resolveSubmissionTemplate` (per-firm slug → HL Patents Style
fallback, same as the editor draft) and run the new service method.
Visibility / rule-not-found semantics route through
`SubmissionVarsService` errors so the gate behavior matches every
other project endpoint. Removed `loadPublishedRuleByCode` and
`errRuleNotFound` — both were only used by the old handler.
- `scripts/gen-demo-submission-template/main.go` + the regenerated
`de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
exercise the bare `{{today}}` alias too. The demo template covers
every one of the 48 keys SubmissionVarsService can resolve (firm 2,
today 4, user 3, project 18, parties 6, rule 8, deadline 7).
The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.
Build + vet + tests clean (go test ./internal/...; bun run build).
|
|||
| a911a2d0ee |
feat(submissions): t-paliad-243 — global Schriftsätze drafts without project
Adds an end-to-end project-optional path for Schriftsatz drafts:
- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
and rewrites the four RLS policies to gate purely on user_id when
project_id IS NULL, otherwise on paliad.can_see_project. Down
refuses to run if project-less rows exist (safer than silent
data corruption).
- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
layer skips project/parties/deadline lookups when nil and exposes
DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
ListAllForUser LEFT JOINs paliad.projects so project-less drafts
surface in the global index next to project-scoped ones.
- New HTTP surface:
GET /submissions/new (picker page)
GET /submissions/draft/{draft_id} (editor for any draft)
GET /api/submissions/catalog (catalog without project)
POST /api/submission-drafts (project-less or attached)
GET/PATCH/DELETE /api/submission-drafts/{draft_id}
POST /api/submission-drafts/{draft_id}/export
Existing /api/projects/{id}/submissions/... routes remain bit-
identical so the project-scoped flow keeps working unchanged.
- Frontend: /submissions/new lists the full cross-proceeding catalog
grouped by proceeding, filterable by text + chip. Each row offers
"Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
with autocomplete over visible projects). /submissions index gains
a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
the picker. The editor renders a banner + "Projekt zuweisen"
action when project_id is null; assigning persists project_id and
redirects to the project-scoped URL.
Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
|
|||
| 436c1b41bb |
feat(submissions): t-paliad-240 — Schriftsätze sidebar + global drafts index
Add a top-level Schriftsätze entry under the Werkzeuge sidebar group
plus a new /submissions page that lists every draft the caller owns
across visible projects. Each row links to the per-project editor at
/projects/{id}/submissions/{code}/draft/{draft_id}.
Backend: SubmissionDraftService.ListAllForUser joins paliad.submission_drafts
with paliad.projects, gated by paliad.can_see_project for visibility. New
GET /api/user/submission-drafts endpoint exposes the rows; the page route
GET /submissions is gateOnboarded'd alongside the other project surfaces.
Frontend: submissions-index.tsx renders an entity-table; submissions-index.ts
hydrates from /api/user/submission-drafts and wires the row-click contract
(skip clicks on inner a/button). DE primary, EN secondary i18n.
|
|||
| 2c5f85b802 | Merge: t-paliad-238 Slice A — dedicated Submissions draft editor + merge engine | |||
| 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
|
|||
| 3ff1b23238 |
fix(timeline): t-paliad-237 — anchor lookup must traverse linked proceedings
On a CCR sub-project the SmartTimeline renders the parent inf project's
rules in the parent_context lane (correct — the CCR depends on the inf
schedule). Clicking "Datum setzen" on those rows bubbled up as a
generic "Konnte das Datum nicht setzen." because RecordAnchor only
looked up the rule under the CCR's own proceeding_type_id; for an
inf rule like upc.inf.cfi.soc that returned sql.ErrNoRows and dropped
into the catch-all error.
The anchor handler now mirrors the read view's broader rule scope: on
sql.ErrNoRows for a CCR project, we retry the lookup against the
parent project's proceeding_type_id. If the rule is found there, we
reject with a new CrossProceedingAnchorError carrying the parent
project's id + title so the frontend can render a clear DE/EN message
and a clickable link back to the parent ("anchor it on the
infringement proceeding, not the counterclaim"). We deliberately do
NOT auto-route the write across projects — that would silently mutate
the inf project's actuals and is out of scope per the brief.
Genuine "unknown submission_code" failures still surface as
ErrInvalidInput; the predecessor_missing 409 path keeps its existing
shape (the two errors discriminate on the response's `error` field).
Adds a Live-DB integration test that seeds an inf-only rule + a CCR
under a real inf project and verifies all three paths: CCR rejects
cross-proceeding, parent inf project accepts the same code, unknown
codes still report unknown_submission_code.
|
|||
| 903225b593 | Merge: tesla — dashboard widget size clamp on read + write | |||
| 4cd2f05d33 |
fix(dashboard): t-paliad-238 — hidden widgets render at proper size in edit mode
Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.
Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
pass places hidden widgets after the visible pass — collision-aware
+ cursor-aware so the hidden tray stacks below the active layout
without ever displacing a visible widget. applyLayout() passes
includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
their stored coordinates so un-hiding restores them in place).
Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
catalog Min/Max + grid bounds on load. Stale rows with W below MinW
(or above MaxW, or X+W overflowing the grid) heal on the next
/api/me/dashboard-layout GET and the cleaned spec is persisted
back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
exists to recover users who got into a bad state under the old
rules.
Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
behaviour (hidden skipped by default, two-pass ordering, multi-
hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
W=0 sentinel, negative X) plus a round-trip Validate guarantee.
|
|||
| 65308651dd |
fix(projects): three project-detail page hotfixes
m hit a cluster of three bugs on /projects/{id}/submissions:
1. 500 on /api/projects/{id}/partner-units — DerivationService.AttachedUnit
scanned derive_unit_roles (text[]) into a plain []string. sqlx returns
[]uint8 for array columns without an adapter. Swap to pq.StringArray
(same shape as the other array-scanned types in the codebase).
2. 404 on /projects/{id}/submissions — every other project-tab path
(history, deadlines, team, checklists, …) is registered in handlers.go
routing all to handleProjectsDetailPage so deep links work, but the
submissions tab added in t-paliad-230 never got the matching route.
Result: m navigates to the share-able URL and gets the 404 chrome.
Add the missing route entry.
3. Create / update project rejected by projekte_client_number_check —
the CHECK is `client_number IS NULL OR matches '^[0-9]{6}$'`, but the
form sends empty string "" for an unset field. The Create path passed
`*input.ClientNumber` raw; the Update path's appendSetSkippable did
the same. Both now route through a new nullableTrimmed helper that
coerces empty/whitespace to nil → SQL NULL → constraint accepts.
matter_number gets the same treatment for symmetry.
Verified the SQL by EXPLAIN against the live DB on the today-filter
hotfix (
|
|||
| becf4f0ce3 |
hotfix(deadlines): use date(completed_at) not ::date — sqlx named-param bug
Production down (Termin/Fristen list returning 503) since 13:28 UTC on
my own
|
|||
| 924dbd9768 | Merge branch 'mai/knuth/coder-paliadin-chat' | |||
| 007ebc2794 |
fix(deadlines): "Heute" filter retains deadlines completed today
m's 2026-05-22 observation: checking off a deadline on the Heute view made it disappear immediately — no sense of progress, no record that the day's work is getting done. Root cause: filterDeadlines was filtering on `status = 'pending'` even for the Heute bucket. The bucket should be a date-scoped view of the day's deadlines, not a pending-only queue. Fix: include items where `due_date = today` AND either still pending OR completed today (`completed_at::date = :today`). Items completed on earlier dates still drop out — the bucket stays "today's work" rather than "everything that was ever due today". Frontend already renders completed deadlines as strikethrough/green via `frist-urgency-done` (see frontend/src/client/events.ts:254), so no client change needed. Dashboard counter (`SELECT … COUNT(*) FILTER WHERE status='pending'`) intentionally unchanged — keeps the lime card a "remaining to do" indicator, like an unread-mail badge. |
|||
| cdd27d674e |
feat(paliadin): stream + honest late-recovery (t-paliad-235)
m's 14:56 observation: long Paliadin turns showed "Verbindung verloren —
Antwort wird nachgereicht …" but never delivered. The aichat backend
finished the turn upstream; paliad's HTTP client had given up at 130 s
and the legacy filesystem janitor never ran for the aichat path.
Three intertwined fixes, all shipped together because they share the
same wire shape and the same UI states:
1. Switch the aichat backend to /chat/turn/stream
- new AichatPaliadinService.RunTurnStream relays incremental chunks
- SSE parser handles default `data:` frames (chunk/meta/done/error)
and named `event: heartbeat` frames per the upstream contract
- no more 130 s hard ceiling — stream stays open as long as data or
heartbeats flow; silenceTimeout (90 s) catches a true upstream
stall instead
2. Proof-of-life thinking events
- handler emits `event: thinking` every 5 s while the upstream is
silent (synthesised locally) AND relays aichat's `heartbeat`
events as thinking pings
- frontend renders a lime-dot pulse + monospace counter inside the
assistant bubble — the user can SEE the chat is still working
3. Honest disconnect copy + real late-recovery
- new dispatching endpoint GET /api/paliadin/turns/{id}/recover
- aichat backend: asks aichat via GET /chat/conversations and
/chat/conversations/{id}/turns whether the turn actually finished
- legacy backend: falls through to the local row read (janitor)
- frontend swaps "wird nachgereicht" → "Lade frische Antwort …"
while the recovery polls; on confirmed "lost" swaps to
"Antwort konnte nicht zugestellt werden — bitte erneut stellen"
- migration 118 adds aichat_conversation_id to paliadin_turns so
the recovery has a fast path when the done frame arrived before
the drop
Streaming + recovery are a no-op for PALIADIN_BACKEND=legacy: the
StreamingPaliadin interface is detected via type assertion, the
LocalPaliadinService stays on the one-shot RunTurn + filesystem
janitor path.
13 new unit tests cover the SSE parser, the conversation-API client,
and the match-assistant-response helper.
go build ./... + go test ./internal/... + go test ./cmd/server/...
+ bun run build all clean.
|
|||
| 17d149c09e |
hotfix(paliadin): ship user_id on /chat/turn (aichat tenant-DB requirement)
m reported "ai chat seems not to be wired anymore" + the frontend
showed "Verbindung verloren. Antwort wird nachgereicht…".
Root cause: aichat on mRiver added a tenant-DB layer that demands
`user_id` on every /chat/turn request:
{"error":{"code":"bad_request",
"message":"user_id is required when a tenant DB is
configured","retryable":false}}
aichat itself is healthy (/chat/health 200, paliadin session ok:true,
last successful turn was ~2.6h ago). The paliad side built and shipped
an aichatTurnRequest without user_id, so every turn since the tenant-DB
flip 400s; paliad's SSE relay receives no upstream data and closes
empty, producing the user-visible "Verbindung verloren".
Fix: add UserID to aichatTurnRequest (json: user_id, mandatory now),
populate from req.UserID.String() at the call site. The userID was
already in scope (used for JWT mint + username lookup); the struct just
wasn't shipping it.
Regression test in TestRunTurn_HappyPath_ViaCallHTTP asserts
captured.UserID == request UUID so a future struct edit that drops the
field fails CI instead of production.
|
|||
| da8389b6e3 |
feat(projects): t-paliad-232 Verfahrenstyp picker + Schriftsätze CTA
Two-part fix from m's 2026-05-21 finding that the Schriftsätze tab
told users "Bitte zuerst einen Verfahrenstyp setzen" while the
project form had no field to set it. The `proceeding_type_id`
column was already on `paliad.projects` and accepted by the API.
Part 1 — Verfahrenstyp picker on the case-fields block
* frontend/src/components/ProjectFormFields.tsx — new optional
<select id="project-proceeding-type-id"> rendered between
Aktenzeichen and Mandantenrolle inside the type=case block.
First option is "(nicht gesetzt)" / "(unset)".
* frontend/src/client/project-form.ts — shared
loadProceedingTypes() + populateProceedingTypeSelect()
helpers. Options sorted by `code` (de.* → dpma.* → epa.* →
upc.*). readPayload sends `proceeding_type_id` only when the
user picked a value; prefillForm restores the saved id via
dataset.preselect to survive the async populate race.
* frontend/src/client/projects-new.ts — kicks off populate on
DOMContentLoaded.
* frontend/src/client/projects-detail.ts — edit-modal preload
now awaits populate; the local loadProceedingTypes duplicate
(used by the counterclaim modal) is replaced by the shared
helper so both surfaces hit the same cache.
Part 2 — Actionable empty-state on the Schriftsätze tab
* frontend/src/projects-detail.tsx — the static <p> empty-state
becomes a div with a "Projekt bearbeiten" button.
* frontend/src/client/projects-detail.ts — openEditModal now
accepts an optional focusFieldID; the new
#project-submissions-edit-cta click handler calls it with
"project-proceeding-type-id" so the picker is scrolled into
view and focused right after the modal opens.
i18n: new keys projects.field.proceeding_type{,.unset,.hint} and
projects.detail.submissions.empty.no_proceeding.cta; reworded
no_proceeding copy to match the new "edit the project" CTA.
Backend already validates via validateProceedingTypeCategory
(mig 087/088 fristenrechner-category guard). Added
TestProjectService_CaseProceedingTypePicker exercising both the
happy and reject paths through a `case`-typed Create.
Manual test path: open any case project → Edit → the Verfahrenstyp
picker shows below Aktenzeichen → save → the Schriftsätze tab now
lists the submission codes. Clicking the empty-state CTA jumps
straight to the picker.
|
|||
| 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).
|
|||
| 47b869dddf |
hotfix(projects): drop ltree operators on text path — production outage
Production-down: project tree returned the "Projektverwaltung zurzeit nicht verfügbar" message because every PopulateProjectCodes call raised: ERROR service: populate project codes: bulk fetch: pq: operator does not exist: text @> text at position 13:38 (42883) Root cause: paliad.projects.path is stored as TEXT (dot-separated UUIDs), not as the ltree extension type. The rest of the codebase treats it accordingly — can_see_project uses string_to_array(path, '.')::uuid[]; export_service.go uses LIKE patterns; export_service.go even spells it out: "Subtree-aware queries via paliad.projects.path (ltree as text)." The new project-code helper (t-paliad-222 / m/paliad#50) was the only caller using ltree operators (@>, nlevel) against this text column. Postgres correctly rejected text @> text — no such operator exists. Fix: rewrite both queries (BuildProjectCode + PopulateProjectCodes) to walk ancestors via string_to_array(path, '.')::uuid[], consistent with the existing visibility predicate. Ordering uses array_position instead of nlevel. Query shape validated against the live DB. Pure-function tests (assemble + segment) untouched and passing. The gap that let this ship: no integration test exercises the actual SQL — it only tests the pure assembler. Filing a follow-up issue for a real-DB regression test. |
|||
| f8245a06a6 |
fix(dashboard): t-paliad-227 — rebuild edit mode on a single 12-col grid (m/paliad#69)
Three issues from Slice B were entangled in the same root cause:
1. **Drag/drop reorder only swapped the first two same-size widgets.**
Widgets lived in two parents (.container + .dashboard-columns); the
old applyLayout used parent.appendChild per widget which physically
moved every .container widget to the END of .container — past the
.dashboard-columns row, edit-footer, and save-toast. Only the two
columns inside .dashboard-columns swapped visibly because they
shared a parent. Cross-row drags appeared to silently no-op.
2. **No resize affordance** — the design's per-widget sizing existed
only on paper.
3. **Per-widget options were thin** — count + horizon dropdowns only.
This change rebuilds the whole layout primitive on a single 12-column
CSS grid:
Backend (internal/services/):
- DashboardWidgetRef gains x/y/w/h grid coordinates. Validator clamps
against catalog MinW/MaxW/MinH/MaxH and rejects x+w > 12.
- WidgetDef gains DefaultW/H + MinW/MaxW/MinH/MaxH for the resize clamps.
- WidgetSettingsSchema gains Views ([{id,label_de,label_en}]), CountMax,
HorizonMax. Validator accepts free-form ints inside [1,CountMax] in
addition to dropdown presets, plus view-id against schema.
- WidgetCatalog wires views for upcoming-deadlines/-appointments (list,
calendar), inline-agenda (timeline, list), recent-activity (full,
compact), plus default sizes per widget.
- FactoryDefaultLayout greedy-packs visible widgets onto the grid,
tracking row-max height so taller previous neighbours never overlap.
Frontend:
- dashboard.tsx: every widget moved into a single .dashboard-grid
wrapper; matter-summary converted to a CollapsibleSection so it
participates in the grid like everything else.
- applyLayout rewritten — never moves DOM nodes; writes inline
grid-column / grid-row from computed placements. computePlacements
trusts explicit positions and auto-flows the rest with the same
rowMaxH-aware packer the backend uses.
- reorderViaDnd swaps (x, y) instead of array order; layout re-sorted
by (y, x) so the persisted array matches visual order.
- Resize handles in edit mode: bottom-right pointer-drag, cellW/cellH
derived from live grid metrics, snaps to grid + clamps to schema,
autosaves on pointerup. Native HTML5 DnD suppressed during resize.
- afterLayoutMutation now materialises every visible widget's
(x,y,w,h) so the spec stays self-describing — no mixed
explicit/auto-flow on next render.
- Gear popover expanded: view segmented control, custom count/horizon
numeric inputs alongside preset dropdowns, size (W/H) + position
(X/Y) spinners. Every visible widget gets a gear in edit mode.
- View-aware renderers:
- upcoming-deadlines / -appointments: list (default) or mini-month
calendar with item dots.
- inline-agenda: timeline (default) or flat list.
- recent-activity: full (default) or compact (one-line per row).
CSS:
- .dashboard-grid (12 cols, dense auto-flow); collapses to single
stack on narrow viewports.
- .dashboard-widget__resize handle (bottom-right diagonal stripes).
- .dashboard-widget__view-group segmented control.
- .dashboard-cal-* mini-calendar.
- .dashboard-activity-list--compact one-line variant.
- Grid items get card chrome via .dashboard-grid > .dashboard-section.
Tests:
- New: AcceptsCustomCountWithinMax, AcceptsValidView,
RejectsUnknownView, RejectsViewOnNoViewWidget, GridPosition,
GridSizeOutsideClamps, NoOverlap (greedy packer regression),
AssignsPositions.
- Updated: BadSettings now asserts a value above CountMax (free-form
values inside [1,CountMax] are valid; presets stay valid too).
Backwards-compatible: a stored layout without x/y/w/h still loads — the
client's auto-flow placer puts widgets into a clean single column until
the user customises. The first drag / resize / settings tweak
materialises all positions so subsequent renders are deterministic.
|
|||
| 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 |
|||
| fffddcc71a |
feat(checklists): t-paliad-225 Slice C backend — template versioning + catalog Version
m/paliad#61 Slice C backend. Schema (mig 116, idempotent): - ALTER paliad.checklists ADD COLUMN version int NOT NULL DEFAULT 1. Pre-Slice-C rows default to 1 (the column was added with DEFAULT so the UPDATE clause is a no-op safety net). - ALTER paliad.checklist_instances ADD COLUMN template_version int. NULL on existing rows — instance detail page leaves the "outdated" badge off when the snapshot version is unknown. Services: - ChecklistTemplateService.Update — version bumps on title/body changes (the meaningful edits that warrant notifying instance owners). Pure metadata tweaks (description/court/reference/deadline) update updated_at without bumping. Emits the new 'checklist.versioned' audit event with prior_version + new_version metadata. - ChecklistInstanceService.Create — captures snapshot_version alongside the body snapshot. - ChecklistCatalogService — CatalogEntry grew a Version field (1 for static; live column for authored). ListVisible / Find populate it. - Models — Checklist.Version int; ChecklistInstance.TemplateVersion *int. - /api/checklists/{slug} response now includes version so the instance detail page can compare against the snapshot. Migration verified live via BEGIN..ROLLBACK against paliad.checklists and paliad.checklist_instances. Build hygiene: go build/vet/test ./internal/... + TestBootSmoke ./cmd/server/ all green. |
|||
| 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. |
|||
| ea0715a8c7 |
feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived project codes from the ancestor tree) in one shift. Migrations: - mig 112_client_role_rework: widen paliad.projects.our_side CHECK to seven sub-roles (claimant / defendant / applicant / appellant / respondent / third_party / other); drop legacy 'court' / 'both' and backfill rows to NULL (no-op on prod, defensive on staging). - mig 113_projects_opponent_code: add paliad.projects.opponent_code text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as the middle segment when assembling auto-derived project codes. Backend: - internal/services/project_code.go — new package-level helpers BuildProjectCode (single row) + PopulateProjectCodes (bulk, one CTE-based round-trip). Walks the existing paliad.projects.path ltree; custom paliad.projects.reference on the target wins. - Wired into ProjectService.List, GetByID, ListAncestors, GetTree, LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every service entry-point that returns []models.Project / *models.Project populates .Code before returning. - Models: Project.OurSide doc widened; new Project.OpponentCode (db:"opponent_code") and Project.Code (db:"-", projection-only). - CreateProjectInput / UpdateProjectInput accept OpponentCode; validateOpponentCode + nullableOpponentCode mirror our_side helpers. - validateOurSide widens to the seven sub-roles; legacy 'court' / 'both' rejected at the service layer with a clear error before the DB CHECK fires. - derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent, appellant → respondent; third_party / other / NULL pass through. - submission_vars: project.code added to the placeholder bag. ourSideDE / ourSideEN now use the gender-neutral "-Seite" / "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...); better legal-prose default for a B2B patent practice, matches the form labels which already used this shape (cf. head's soft-note on Q4). Frontend: - ProjectFormFields: opponent_code on a new projekt-fields-litigation block (hidden by default, shown when type=litigation); our_side moved into projekt-fields-case and re-labelled "Client Role" / "Mandantenrolle" with three <optgroup>s + seven options. - project-form.ts: showFieldsForType toggles the new litigation block; readPayload / prefillForm wire opponent_code; our_side is now only emitted for type=case. - fristenrechner: ourSideToPerspective widened to the seven sub-roles (Active→claimant, Reactive→defendant, Other→null). ProjectOption type literal updated. - i18n.ts: new projects.field.client_role.* and projects.field.opponent_code.* keys (DE+EN). Legacy projects.field.our_side.* keys stay one release for cached bundles + Verlauf event-history rendering of the new sub-roles. Tests: - TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3, TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode, TestValidateOurSideSubRoles pin the new pure helpers. - TestOurSideTranslations widened to the seven sub-roles + new prose shape; 'court'/'both' arms now return "" (legacy rejected). - TestDerivedCounterclaimOurSide widened to the new flip map. Migration slot history (this branch was rebumped twice on 2026-05-20): mig 110 was claimed by m/paliad#51 (project_type_other, euler); mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss). Final slots 112 / 113. go build && go test ./internal/... && cd frontend && bun run build all clean. |
|||
| 3fdc969902 | wip(projects): bump migrations 110→111, 111→112 (euler claimed 110) | |||
| 5dea0a703b |
wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main), validators + helpers widened, BuildProjectCode helper + projection populator wired into List/GetByID/ListAncestors/GetTree/CCR. All internal Go tests pass. Frontend: ProjectFormFields conditional render — opponent_code on litigation, our_side renamed to Client Role on case with grouped optgroups. i18n keys for both DE and EN. fristenrechner perspective mapping widened. project-form.ts payload reader/writer + showFieldsForType toggle for new litigation block. Migration slots about to be bumped (mig 110 was claimed by euler's project_type_other on main). |
|||
| ea9823db80 |
fix(verfahrensablauf): m/paliad#58 — UPC CCR roadmap (EN label + spawn-as-standalone)
m's 2026-05-20 14:08 reports on /tools/verfahrensablauf:
1. "There seems to be a lacking english term here" — picking
UPC CCR shows "Trigger event: Widerklage auf Nichtigkeit" on EN.
2. "Nothing shows in the roadmap" — the timeline is empty because
upc.ccr.cfi has no native rules (it's an illustrative peer that
normally runs as a sub-track of upc.inf.cfi with with_ccr).
Root cause for (1): UIResponse.proceedingName was DE-only. When a
proceeding had no root rule the frontend fell back to that field, so
EN users saw the DE label. The DB already has bilingual names; this
was pure plumbing.
Root cause for (2): the upc.ccr.cfi proceeding-type row exists for
the picker (mig 096) but ResolveCounterclaimRouting — the helper
that maps it to upc.inf.cfi with the with_ccr flag — was defined
but never called. Calculate queried rules directly off upc.ccr.cfi
and got an empty list.
Fix:
* Add ProceedingNameEN, ContextualNote, ContextualNoteEN to
UIResponse. Frontend triggerEventLabelFor now consults the EN
name on EN, falling back to DE only if the EN field is empty.
* New SubTrackRouting registry in proceeding_mapping.go and a
LookupSubTrackRouting lookup — single source of truth for the
"this proceeding has no native rules, route to a parent with
flags + show a contextual note" pattern. Today's only entry is
upc.ccr.cfi → upc.inf.cfi + with_ccr; the pattern generalises
to other sub-tracks via data-only additions.
* Calculate consults the registry at the top: when a hit, the
proceeding type is re-resolved to the parent for rule lookup, the
default flags are merged into the user's flag set (user flags win
on conflict), and the response identity (Code/Name/NameEN) stays
on the user-picked proceeding so the page header still reads
"Counterclaim for Revocation". The bilingual note surfaces in
ContextualNote{,EN}.
* Frontend renderResults paints a lime-accent banner above the
timeline body when the response carries a note
(.timeline-context-note). escHtml already exported from
views/verfahrensablauf-core — imported here for the banner.
No DB migration: SELECTs against paliad.proceeding_types,
paliad.deadline_rules, and paliad.trigger_events confirm every
active row already has a non-empty name_en / name. The bug was
the API + frontend never reading the EN columns through the
proceedingName fallback path.
Tests: TestSubTrackRoutings pins the registry shape (every entry
has matching key/value, non-empty parent+flags, bilingual notes;
CCR's exact shape is asserted; non-sub-tracks miss). The existing
TestResolveCounterclaimRouting continues to pass because the
helper now consults the registry but the CCR semantics are
unchanged.
|
|||
| 2ed0ef3177 |
feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an inheritable role-edit gate via the materialised ltree path. - migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase. - services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate. - services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column). - services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError. - handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage. - handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip. - frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg. - i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs). - tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table. go build && go test -short ./internal/... && bun run build all clean. |
|||
| 072b3d0c3d |
fix(events): drop broken 'From Today' appointment filter; default to today
m/paliad#54 (t-paliad-221) — fix
|
|||
| dc5f11ddef |
feat(projects): add 'other' as a real type; drop synthetic Empty filter
m/paliad#51 (t-paliad-221) — the type chip filter on /projects used to treat unclassified projects as a synthetic "Empty" bucket. Make 'other' a first-class projects.type value so every row carries a meaningful label and the filter UI stops needing a NULL/Empty shim. - mig 110: extend projects.type CHECK to include 'other'; backfill any NULL rows defensively (production query confirmed zero, but the NOT NULL constraint isn't load-bearing once the IN-list changes). - Go: add ProjectTypeOther constant; isValidProjectType + humanProjectType recognise it; handler doc lists 'other' in the ?type whitelist. - Frontend: new chip in the projects.tsx type filter, new option in the Create-Project form, DE "Sonstiges" / EN "Other" labels for the projects.type and projects.chip.type i18n families. Also drops a stray data-i18n-text attribute on the existing 'project' chip checkbox (it had no consumer in i18n.ts and the surrounding markup was nesting a <span> inside an <input>). |
|||
| 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.
|
|||
| a421bff856 |
feat(dashboard): t-paliad-219 Slice A1 — user_dashboard_layouts storage + service
Migration 109 + DashboardLayoutSpec + Service + WidgetCatalog. No HTTP
handlers and no frontend yet — those land in A2/A3/A4 as separate commits
for cleaner review.
Why slot 109 (not 107 from the design doc): leibniz claimed 107 for
caldav_sync_log.binding_id and 108 for caldav_mkcalendar_capability after
the design was filed. Boltzmann's gap-tolerant runner (
|
|||
| fbd087e0cd |
feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.
Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.
CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
MKCALENDAR, falls back to a synthetic MKCALENDAR against a
random .paliad-probe-XX/ path (with DELETE cleanup) to catch
legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
supported-components; returns ErrCalendarNameTaken on 405 so
the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.
Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
/api/caldav-discover call after credential change; result persisted
via UPDATE on user_caldav_config. DiscoverCalendars response now
carries supports_mkcalendar so the UI can show / hide the create-new
radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
via the client (with 3-try -XX-suffix retry on name collision),
creates the matching binding, kicks off PushBindingNow. Returns
the partial result on push failure so the UI can show "created but
initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
re-configured server gets re-probed on next open.
HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
include_personal?} → 201 {calendar_path, binding, initial_pushed}.
Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
upstream. Partial-success (binding created, push failed) carries
initial_sync_error in the body so the UI can surface both bits.
Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
Create radio is visible only when supports_mkcalendar=true;
when false, the bilingual Google-degrade notice is shown
beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
/api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
+ caldav.bindings.error.create_*.
Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
bun run build all clean.
Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
|
|||
| 1fcfab7791 |
feat(caldav): Slice 2b write APIs + picker UI (t-paliad-212)
User-visible Slice 2 milestone: the /einstellungen/caldav Kalender
section now lets a user pin multiple calendars to Paliad via a
single-step add modal (Q3 of the Slice 2 brief). m greenlit
"all yes / all R" on 2026-05-20, so this lands with: synchronous
first-push on POST (Q5), lazy cleanup on PATCH scope change (Q6),
5-minute server-side cache on /api/caldav-discover (Q4),
calendar_path retained-but-deprecated (Q7).
Backend
- CalDAVService.PushBindingNow — runs one push pass for a single
binding synchronously; called from POST /api/caldav-bindings so
the modal closes with events already landed.
- CalDAVService.RemoveBinding — best-effort remote-event DELETE +
binding row drop (§2.6 of brief). On partial remote failure,
the binding is disabled instead of dropped and the handler
surfaces 202 Accepted.
- CalDAVService.EnsureLoop — spawns the per-user sync goroutine
for users who didn't have one before this request.
- CalDAVService.DiscoverCalendars — walks current-user-principal
→ calendar-home-set → child PROPFIND (RFC 6764 §6 / RFC 6638
§10). Cached 5 minutes per user; invalidated on SaveConfig /
DeleteConfig.
- caldav_client.go gains DiscoverCalendars + propfindHrefs +
listCalendars + supporting multistatus types. VEVENT-only
filter skips iCloud reminder lists / addr books.
HTTP API
- POST /api/caldav-bindings — create binding + sync first-push;
201 with binding + initial_pushed count, or 201 with
initial_sync_error when the push fails after binding creation.
- PATCH /api/caldav-bindings/{id} — partial update.
- DELETE /api/caldav-bindings/{id} — calls RemoveBinding;
responds 204 (full cleanup) or 202 (partial — binding disabled
for next-tick retry).
- GET /api/caldav-discover — returns {calendars, calendar_home}
for the picker.
Frontend
- /einstellungen/caldav Kalender section: list of binding cards
with enabled toggle / Edit / Remove. "+ Kalender hinzufügen"
opens the single-step modal.
- Single-step add modal: source picker (discovery dropdown or
custom URL toggle) + scope radio (all_visible / personal_only
/ project + project picker) + display name. Edit mode reuses
the modal with the source field hidden.
- 32 new i18n keys under caldav.bindings.* (DE primary, EN
parallel) covering modal copy, card actions, error messages,
delete-confirm, scope labels.
Verification
- Live Supabase BEGIN..ROLLBACK: full CRUD flow exercised
(create → patch display_name → patch scope → second
all_visible after the first scope-shifts → delete);
the partial unique index frees correctly when scope moves
off all_visible, no race or constraint surprise.
- go build ./... + go test ./internal/... + bun run build all
clean.
|
|||
| f0b08e9d06 |
feat(approvals): t-paliad-217 Slice B — counter_payload allowlist expansion
m's t-paliad-217 Q1 lock-in (2026-05-20): the suggest-changes modal lets
the approver edit EVERY field on the underlying deadline / appointment,
not just the date allowlist that triggers approval. Server-side support
for the wider counter shape:
- buildCounterSetClauses (new) — the counter-allowlist:
deadline: title, due_date, original_due_date, warning_date,
description, notes, rule_code (event_type_ids handled
separately via junction-table rewrite).
appointment: title, start_at, end_at, description, location,
appointment_type.
- buildRevertSetClauses (existing) stays narrow — Reject only restores
what pre_image actually contains (defence-in-depth: a hostile UPDATE
on the request row must not let arbitrary fields be reverted, and
pre_image is server-written so what's in there is what we trust).
- rewriteDeadlineEventTypes — junction-table DELETE+INSERT for the
deadline_event_types m-to-m when counter_payload carries
event_type_ids. Runs in the same tx as the entity UPDATE.
- applyEntityUpdate — switched from buildRevertSetClauses to
buildCounterSetClauses; gained the event_type_ids branch for
deadlines.
- SuggestChanges no-op validator — now uses buildCounterSetClauses
so the wider field set counts as "differs".
- title is treated as NOT NULL — whitespace-only counter title
surfaces ErrSuggestionRequiresChange (defence-in-depth against the
column's own NOT NULL CHECK).
Tests:
- TestApprovalService_SuggestChanges_TitleOnlyCounter — title diff
succeeds; entity title updates.
- TestApprovalService_SuggestChanges_NotesOnlyCounter — notes diff
succeeds; entity notes column populates.
- TestApprovalService_SuggestChanges_EmptyTitleRejected — whitespace-
only title rejected with ErrSuggestionRequiresChange.
No DB migration needed (counter_payload jsonb already accepts arbitrary
shape; the change is in the column-allowlist switch on read).
|
|||
| 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. |
|||
| 8f1f88b517 |
feat(export): t-paliad-214 Slice 2 backend — project-subtree sync export
Adds GET /api/projects/{id}/export?direct_only=0|1 streaming a
deterministic project-subtree bundle in the same xlsx + JSON + per-sheet
CSV shape as Slice 1's personal export. 16 entity sheets per design §2:
projects + project_teams + project_partner_units + deadlines +
appointments + parties + notes (4-way polymorphism resolved) + documents
(metadata only) + project_events + approval_requests + approval_policies
(triple-source attribution with `source` column for Q4 lock-in) +
checklist_instances + partner_units (attached only) +
partner_unit_members (members of attached units only) + users_referenced
(FK-referenced users only) + system_audit_log_subset. Personal sidecars
explicitly excluded; reference sheets (proceeding_types, event_types,
deadline_rules, courts, …) ship for standalone interpretability.
§4 permission gate enforced server-side:
- global_admin can export anything, OR
- direct project_teams membership with responsibility ∈ {lead, member}
- Observers + Externals + derived-only partner-unit users → 403
bilingual ("Datenexport ist nur Team-Mitgliedern (Lead / Member)
vorbehalten / Data export is restricted to project team members").
Cross-subtree FK detection (Q3 lock-in: keep + warn) runs one
lightweight SELECT against projects.counterclaim_of and appends one
warning row to __meta.warnings per outbound reference. Recipients can
choose to keep or strip the FK on re-import.
Filename includes 8-hex-char short-uuid disambiguator (Q5 lock-in):
paliad-export-project-<slug>-<short-uuid>-<ts>.zip — two projects with
identical titles produce different filenames even when archived
together.
Audit row in paliad.system_audit_log (no new migration — already
supports scope='project'): metadata carries root_label + root_path
(ltree) + direct_only flag (Q6 lock-in) so the audit row remains
interpretable after the project is deleted.
__meta sheet + README.txt extended to surface project-scope fields:
scope_root_label, scope_root_path, direct_only.
ExportFilename signature extended to take a rootID; Slice 1 callsite
updated to pass uuid.Nil.
8 new pure-function tests pin: sheet registry shape (24 sheets in
order), triple-source approval_policies SQL tags, direct_only narrows
subtree to root-only, no-personal-sidecars guard, attached-only
partner_units filter, shortUUIDSuffix shape, project-scope meta rows,
short-uuid filename collision avoidance.
|
|||
| 1765d5e55f |
feat(submissions): t-paliad-215 Slice 2 — patent_number_upc helper
UPC briefs parenthesise the patent kind code ("EP 1 234 567 (B1)")
where the DE convention runs it inline ("EP 1 234 567 B1"). Slice 2
adds the {{project.patent_number_upc}} placeholder for the new UPC
templates (Q-S2-4 locked at 'all yes' on 2026-05-20).
Pure function alongside legalSourcePretty. Trailing single-letter +
single-digit kind code regex; everything else preserved. Pass-through
on unrecognised shapes — the lawyer's draft never sees a number worse
than the source value.
Wired into addProjectVars so every render exposes both forms
({{project.patent_number}} and {{project.patent_number_upc}}). UPC
templates pull the parenthesised form; DE templates ignore it.
8 test cases (more than the 6 in the brief) covering:
- EP B1 / EP A1 — common case
- DE national with kind code
- No kind code → pass-through
- Whitespace trimming
- Empty input
- WO publication number (no kind-code shape) → pass-through
- Two-digit kind code (B12) → pass-through (intentional — real EP
kind codes are single-letter + single-digit)
No schema change, no migration, no var-bag namespace additions
beyond the one new placeholder.
|
|||
| 0263a0e932 |
feat(approvals): t-paliad-216 — server-side hydration for back-link
Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:
- ApprovalRequestView gains NextRequestID. Hydrated via correlated
subquery on previous_request_id; mig 103's partial index makes the
lookup O(1) per row.
- view_service.go approvalRowSubtitle picks up the changes_requested
case ("Abgelehnt mit Vorschlag von <decider>").
- filter_spec.go validRequestStatuses includes "changes_requested" so
user-views can filter on it.
- handlers/approvals.go isValidInboxStatus accepts "changes_requested"
on the /api/inbox/{mine,pending-mine}?status= query. Test case added
to TestParseInboxFilter_DropsUnknownStatus.
|
|||
| d924ab9743 |
test(approvals): t-paliad-216 SuggestChanges service + handler error mapping
Service-level (real DB, gated on TEST_DATABASE_URL like the rest of the
approval suite):
- HappyPath: OLD row → changes_requested; NEW row pending with
previous_request_id back-pointer; entity reflects counter payload;
two project_events emitted (changes_suggested + requested).
- NoOpRejected: identical counter + empty note → ErrSuggestionRequiresChange.
- NoteOnlyAccepted: identical counter + non-empty note succeeds; entity
keeps the original counter values.
- SelfApprovalBlocked: original requester cannot suggest on their own row.
- RequestNotPending: already-decided row rejects suggest-changes.
- LifecycleInvalid: create-lifecycle pending → ErrSuggestionLifecycleInvalid.
- OriginalRequesterCanApproveCounter: m's Q6 model — after the approver
suggests changes, the ORIGINAL REQUESTER (now no longer the new row's
requested_by) can approve the counter themselves provided their
profession qualifies.
- CounterApproverCannotSelfApprove: 4-Augen still holds — the suggesting
approver cannot approve their own counter (ErrSelfApproval on the new row).
Handler-level (pure-Go, no DB):
- SuggestionRequiresChange400: error code mapping.
- SuggestionLifecycleInvalid400: error code mapping.
|
|||
| 705e1a2e79 |
feat(approvals): t-paliad-216 SuggestChanges service method
ApprovalService.SuggestChanges is the fourth approval action — in one
transaction:
1. Validates the OLD pending row (caller satisfies canApprove,
lifecycle in update/complete only, counter differs from old.payload
OR note is non-empty).
2. Closes the OLD row as 'changes_requested' with decision_note +
counter_payload + decided_by + decided_at + decision_kind.
3. Reverts the entity from old.pre_image (reuses applyRevert — same
code path Reject runs).
4. Runs the deadlock check for the NEW row (excluding the suggesting
caller; original requester is no longer excluded).
5. Re-applies the counter_payload to the entity row (via
applyEntityUpdate, mirroring the write-then-approve write).
6. INSERTs a NEW pending approval_requests row authored by the caller
with previous_request_id pointing back at the OLD row.
7. Marks the entity pending + pending_request_id → new row.
8. Emits two project_events: *_approval_changes_suggested + a fresh
*_approval_requested for the new row.
4-Augen still holds: the suggesting caller is the new row's
requested_by, so self-approval on the new row is blocked by the standard
3-layer guard. The ORIGINAL requester is no longer the requested_by of
the new row — if their profession satisfies the required_role they can
now approve the counter themselves.
Adds:
- const RequestStatusChangesRequested = "changes_requested"
- var ErrSuggestionRequiresChange = "suggestion requires counter diff or note"
- var ErrSuggestionLifecycleInvalid = "suggest is only valid for update/complete"
- models.ApprovalRequest.CounterPayload + PreviousRequestID
- Per-row read paths (getRequestForUpdate, approvalRequestViewColumns)
populate the new columns.
|
|||
| 3677c81fbe |
feat(submissions): t-paliad-215 Slice 1 — template registry + variable bag
TemplateRegistry (services/submission_templates.go) walks the
m-locked Q4 fallback chain — templates/{FIRM_NAME}/{code}.docx →
templates/_base/{code}.docx → templates/_base/{family}.docx →
templates/_base/_skeleton.docx — against the Gitea repo
HL/mWorkRepo. SHA-cache + 5-min refresh check, identical pattern to
internal/handlers/files.go's HL Patents Style proxy. Distinguishes
"no template" (chain fallthrough) from "Gitea down" so the handler
can render different UI for each.
SubmissionVarsService (services/submission_vars.go) assembles the
~30-placeholder bag from project + parties + rule + next-deadline +
user + firm + today. Locale-aware long-date forms (DE + EN) and a
legal_source pretty-printer that rewrites DE.ZPO.276.1 → "§ 276 Abs.
1 ZPO" / "Section 276(1) ZPO" for the prefixes the 254-rule corpus
uses today. Unknown prefixes pass through unchanged.
Visibility inherits from ProjectService.GetByID
(paliad.can_see_project) — unauthorised callers get the same
ErrNotVisible that every project surface returns.
|
|||
| 8ea3509b98 |
feat(submissions): t-paliad-215 Slice 1 — in-house .docx render engine
Pure-Go {{path.dot.notation}} placeholder engine + unit tests
(t-paliad-215, design docs/design-submission-generator-2026-05-19.md
§6). Chosen over github.com/lukasjarosch/go-docx because that library
treats sibling placeholders inside one <w:t> run as nested and
refuses to replace them — patent submissions routinely carry multiple
placeholders per paragraph (party blocks especially), so the library
is a non-starter.
Two-pass strategy preserves run-level formatting on the common path:
1. Pass 1: regex replace inside each <w:t>…</w:t> independently —
no format loss for the 99% case where placeholders are intact.
2. Pass 2: paragraph-level merge for paragraphs that still contain
orphan "{{" or "}}" markers (Word fragmented the placeholder
across runs).
Missing placeholders render [KEIN WERT: <key>] / [NO VALUE: <key>]
markers so the lawyer sees the gap in Word rather than getting a 400.
Tests cover: single-run, multi-per-run (the go-docx failure mode),
cross-run merge, missing-marker (DE+EN), XML escaping of special
chars, non-document zip entries preserved, placeholder regex
grammar.
|
|||
| f9ff7b93e8 |
fix(export): xlsx docProps + pane XML — Excel "repairs required" + wrong Modified date
m hit two bugs opening the Slice 1 export in Excel / Windows:
1. **Excel showed a "Repairs required" prompt** on open. Root cause:
the SetPanes call passed only `{Freeze: true, YSplit: 1}` — the
obvious-but-wrong shape. The resulting <pane> XML missed the
`topLeftCell` and `activePane` attributes that Excel requires for
a frozen-row pane (excelize's parser is permissive on re-read but
Excel is strict). Fix: complete the Panes struct (TopLeftCell="A2",
ActivePane="bottomLeft", Selection on bottomLeft) and surface
SetPanes errors instead of `_ =`-ignoring them.
2. **Windows Explorer / Excel's File→Info showed Modified=2006-09-16
("xuri")** — excelize's hardcoded first-commit defaults. Root cause:
buildXLSX never called SetDocProps so the canned defaults leaked.
Fix: SetDocProps({Created, Modified} = meta.GeneratedAt;
Creator = "Paliad (<firm>)"; Title/Description scoped per export).
3. **Bonus**: the outer-zip entry mtimes were stamped 2000-01-01 (the
deterministic constant) so extracted files showed a Y2K Modified
date in Explorer. Now stamped meta.GeneratedAt, which preserves
determinism within an export (same row state + same GeneratedAt →
same bytes, the actual m's-Q6 contract).
Also: set the active sheet to __meta (index 0) after sheet creation so
a future code path that adds/removes sheets can't leave an out-of-range
active-sheet index that would trip a separate "repairs required" path.
Regression tests in dump_export_test.go pin all three fixes by re-opening
the generated xlsx via excelize.OpenReader and asserting:
- docProps Created/Modified == meta.GeneratedAt (RFC 3339 UTC)
- docProps Creator contains "Paliad"
- xlsx bytes never contain "2006-09-16T00:00:00Z" or "<dc:creator>xuri</dc:creator>"
- sheet2/sheet3 raw XML carries topLeftCell + activePane + state=frozen
- outer-zip entries' Modified is within ±2s of GeneratedAt
- developer hatch: DUMP_EXPORT=1 writes /tmp/paliad-export-debug.{zip,xlsx}
for opening in real Excel.
|
|||
| 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. |
|||
| 4131d2e2a6 |
feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:
1. **Jurisdiction prefix on the picked proceeding** — the collapsed
summary chip and the result header now read "UPC Verletzungsverfahren"
/ "DE Verletzungsklage (LG)" instead of the bare proceeding name.
Disambiguates the 4 redundancies in the corpus once the picker
collapses. Driven by .proceeding-group[data-forum] which is already
on every group.
2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
line now shows the first event in the proceeding (e.g. Klageerhebung,
Nichtigkeitsklage) instead of the proceeding name. Populated from
the calc response (isRootEvent=true) on every render; em-dash
placeholder while step 3 hasn't rendered yet. lang-change keeps it
coherent.
3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
stripped the with_ccr / with_amend / with_cci toggles when it lifted
the shared renderer; they never came back. Lifted the 4 existing
rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
preliminary objection, mig 095) — same wiring + show/hide rules on
both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
(R.30 only with a CCR).
4. **Rule references → youpc.org/laws links** — new
BuildLegalSourceURL(src) maps the structured legal_source code to
the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
bodies have no youpc home yet and render as plain display text —
filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
LegalSourceURL so deadlineCardHtml can render <a target="_blank"
rel="noopener"> when the URL is set.
5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
only (EN canonical UPC RoP term stays "Preliminary objection").
Client-side change only — i18n + JSX fallbacks. The matching DB
rename on the two rule-name rows folds into joule's broader mig 097
(legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
applied during the session is captured under that audit reason; the
no-op when joule's mig re-applies is harmless.
Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)
Rebased on origin/main @
|
|||
| a0a3ec32a3 |
fix(mig 098): relax submission_code shape regex to allow digits in suffix
Mig 098 (t-paliad-209, ohm) crash-looped paliad.de prod for ~2h: §6.1 assertion regex `^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$` rejects EPA rule codes that carry the statutory rule number in the suffix — e.g. `epa.opp.boa.r106`, `epa.grant.exa.r71_3`, `epa.opp.opd.r116`, `epa.opp.opd.r79_further`, `epa.opp.boa.entsch2`, `epa.opp.boa.r116`. Migration's UPDATE step succeeds against these rows; the transactional assertion blows them up; rollback leaves the migration tracker dirty at version 98 and the container refuses to start. Patch: allow `[a-z_0-9]` per segment instead of `[a-z_]` in both the SQL assertion (mig 098 §6.1) and the matching Go shape regex (submission_codes_shape_test.go). Same change in both spots so the runtime sanity test stays aligned with the SQL invariant. Manual recovery already applied: forced `paliad.paliad_schema_migrations.version` back to 97 with `dirty=false` so the next deploy retries mig 098 from scratch against the patched file. No data state changed (mig 098 ran inside a transaction and fully rolled back — snapshot table, prefix UPDATE, and column rename all reverted). go build ./... clean. TestProceedingCodeShapeRegexStandalone green. |
|||
| a18b825bee |
feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:
1. **Jurisdiction prefix on the picked proceeding** — the collapsed
summary chip and the result header now read "UPC Verletzungsverfahren"
/ "DE Verletzungsklage (LG)" instead of the bare proceeding name.
Disambiguates the 4 redundancies in the corpus once the picker
collapses. Driven by .proceeding-group[data-forum] which is already
on every group.
2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
line now shows the first event in the proceeding (e.g. Klageerhebung,
Nichtigkeitsklage) instead of the proceeding name. Populated from
the calc response (isRootEvent=true) on every render; em-dash
placeholder while step 3 hasn't rendered yet. lang-change keeps it
coherent.
3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
stripped the with_ccr / with_amend / with_cci toggles when it lifted
the shared renderer; they never came back. Lifted the 4 existing
rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
preliminary objection, mig 095) — same wiring + show/hide rules on
both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
(R.30 only with a CCR).
4. **Rule references → youpc.org/laws links** — new
BuildLegalSourceURL(src) maps the structured legal_source code to
the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
bodies have no youpc home yet and render as plain display text —
filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
LegalSourceURL so deadlineCardHtml can render <a target="_blank"
rel="noopener"> when the URL is set.
5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
only (EN canonical UPC RoP term stays "Preliminary objection").
Client-side change only — i18n + JSX fallbacks. The matching DB
rename on the two rule-name rows folds into joule's broader mig 097
(legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
applied during the session is captured under that audit reason; the
no-op when joule's mig re-applies is harmless.
Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)
Rebased on origin/main @
|