Compare commits

...

20 Commits

Author SHA1 Message Date
mAi
e56cb3b210 feat(checklists): t-paliad-225 Slice C frontend — Geteilte Vorlagen tab + outdated-template badge
m/paliad#61 Slice C frontend pass.

Discovery (Geteilte Vorlagen):
- New 4th tab on /checklists between "Meine Vorlagen" and "Vorhandene
  Instanzen". Filters the merged catalog response to authored entries
  not owned by the caller (firm-visible OR globally-promoted OR
  share-recipient). Tab state round-trips via ?tab=gallery.
- Regime filter pills (UPC / DE / EPA / OTHER) operate independently
  from the main Vorlagen tab.
- Cards show regime badge, item count, author line, visibility chip.
- Self-filter relies on /api/me email match — loadMe() fires once on
  page boot and is idempotent.

Versioning UI on /checklists/instances/{id}:
- "Vorlage aktualisiert" badge appears when the instance's
  template_version is known AND lags the live template version (only
  for authored templates; static templates never bump). Shows "v{from}
  → v{to}" delta.
- "Änderungen anzeigen" button opens a diff modal that compares the
  instance's template_snapshot against the live template body.
  Item-level grouping by (section title, item label). Surfaces added /
  removed / changed items with localised section labels. Empty state
  when only metadata changed.

i18n: 13 new keys per language (DE + EN) under
checklisten.tab.gallery, checklisten.gallery.*, checklisten.filter.other,
and checklisten.instance.{outdated,diff}.*. Total 2666 keys.

Build hygiene: bun run build clean; i18n scan clean. Go build/vet/test
+ TestBootSmoke ./cmd/server/ all green.
2026-05-20 15:50:38 +02:00
mAi
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.
2026-05-20 15:50:21 +02:00
mAi
b850eb755c Merge: t-paliad-225 Slice B — checklist sharing + admin promotion (m/paliad#61)
Second slice. Explicit sharing of personal checklists to user / office /
partner_unit / project + global_admin promote-to-firm / demote.

- mig 115 paliad.checklist_shares (FK to user_id / office_key / partner_unit_id
  / project_id; granted_by; granted_at). Partial indexes per share kind.
- Backend: ListShares / GrantShare / RevokeShare on ChecklistService.
  Promote/Demote on AdminChecklistService — flips visibility to/from 'global'
  and emits checklist_promoted_global / checklist_demoted audit events.
- HTTP routes (under /api/checklists/templates/ + /api/checklists/shares/ +
  /api/admin/checklists/ — all literal-prefixed to avoid the route-collision
  class the hotfix 6b63420 just shipped to address).
- Frontend: 'Teilen' modal on a checklist detail page (recipient picker:
  user / office / partner-unit / project); 'Als global markieren' / 'Aus
  global entfernen' admin buttons (global_admin only).
- RLS extended: select policy allows owner + visibility='firm' + visibility='global'
  + rows present in checklist_shares matching caller's ancestry.

Slice C (discoverability gallery + versioning) follows.
2026-05-20 15:39:56 +02:00
mAi
a93277a072 feat(checklists): t-paliad-225 Slice B frontend — share modal + admin promote/demote on detail page
m/paliad#61 Slice B frontend pass.

Detail page (/checklists/{slug}) gains:
- Provenance line ("Erstellt von <author>") for authored templates,
  populated from the catalog response's owner_display_name.
- Owner action buttons: Bearbeiten (links to
  /checklists/templates/{slug}/edit per the Slice A hotfix), Teilen,
  Löschen. Reveal driven by /api/me email match against the catalog
  response's owner_email.
- global_admin action buttons: "Als Firmen-Vorlage hinterlegen"
  (promote) when visibility != 'global'; "Aus Katalog entfernen"
  (demote) when visibility == 'global'. Reveal driven by /api/me
  global_role.

Share modal:
- Single modal with a kind-picker (Kollege / Office / Dezernat /
  Projekt) and a matching select per kind — sections toggle on the
  active kind.
- Recipient pickers populated from /api/users, /api/partner-units,
  /api/projects (loaded in parallel on open). Office options use the
  canonical 8-key set from internal/offices.
- Existing grants surface in a list under the form with per-row
  Entfernen buttons; Revoke confirms before DELETE.
- Errors surface inline (recipient-required, generic share failure).

i18n: 32 new keys per language (DE+EN) under checklisten.share.*
and checklisten.detail.promote/demote/delete.*. Total 2653 keys.

Build hygiene: go build/vet/test ./internal/... + ./cmd/server/ all
green; bun run build clean.
2026-05-20 15:38:43 +02:00
mAi
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 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
2026-05-20 15:38:30 +02:00
mAi
6b634207c2 Merge: hotfix — disambiguate checklists route conflict (production-down) 2026-05-20 15:34:00 +02:00
mAi
794617cbfd hotfix(checklists): disambiguate /checklists/{slug}/edit → /checklists/templates/{slug}/edit (production-down route conflict)
Go ServeMux refused to register patterns 'GET /checklists/{slug}/edit' (from
dirac's Slice A merge b418705) and 'GET /checklists/instances/{id}' (existing)
because both match '/checklists/instances/edit'. Container crash-looped on
boot since 13:32 UTC; paliad.de returned 404 from Traefik because no app was
listening.

Renaming the new template-edit route to /checklists/templates/{slug}/edit
disambiguates — '/templates/...' is a literal segment so the {slug} is now
strictly under a fixed prefix that can't collide with 'instances'.

Touches:
- internal/handlers/handlers.go:257 — route pattern
- frontend/src/client/checklists.ts:290 — Bearbeiten link
- frontend/src/client/checklists-author.ts:52 — URL parser regex
- frontend/src/checklists-author.tsx — doc comment

go build + bun run build clean.
2026-05-20 15:34:00 +02:00
mAi
b418705775 Merge: t-paliad-225 Slice A — user-authored checklists (m/paliad#61)
First slice of the user-checklist feature. Personal templates + 'Meine Vorlagen'
authoring; private + firm visibility only (explicit sharing to specific
users/offices/units/projects + admin-promotion ship in Slices B + C).

- mig 114 paliad.user_checklists table (owner_id, visibility text, name, sections
  jsonb, created_at). RLS scoped to owner + 'firm' visibility = visible to
  all authenticated users. Verified-via-gap-tolerant-runner.
- ChecklistService — Create/List/Get/Update/Delete + RLS-aware queries.
- HTTP layer — GET/POST /api/checklists, PATCH/DELETE /api/checklists/{id}.
- 'Meine Vorlagen' surface on /tools/checklists with authoring wizard
  (sections + items + visibility radio).

Slice B (share-to-individual + promotion to global) and Slice C (gallery +
versioning) come in follow-up shifts.
2026-05-20 15:24:28 +02:00
mAi
7a1fd81d23 feat(checklists): t-paliad-225 Slice A frontend — Meine Vorlagen + authoring wizard
m/paliad#61 Slice A frontend pass.

Pages:
- /checklists gets a third tab "Meine Vorlagen" between Vorlagen and
  Vorhandene Instanzen — lists owned authored templates with regime
  badge, visibility chip, Bearbeiten / Löschen actions, "Neue Vorlage"
  CTA. Tab state round-trips via ?tab=mine.
- /checklists/new and /checklists/{slug}/edit serve a shared bundle
  (checklists-author.html). Client reads location.pathname to decide
  create vs edit mode; edit mode prefills from /api/checklists/templates/mine.

Wizard:
- Metadata form (title, description, regime UPC/DE/EPA/OTHER, court,
  reference, deadline, language de/en, visibility private/firm).
- Repeating section + item editor — add/remove sections, add/remove
  items per section, label + optional note + optional rule per item.
- Single-language authoring (lang column on paliad.checklists). The
  catalog read layer mirrors the title/description onto both DE and EN
  sides so the existing bilingual frontend renders without a special
  case for authored entries.
- Save POSTs (create) or PATCHes (edit) the template; visibility flip
  on edit goes through its own endpoint so the audit row captures the
  transition.

Merged catalog:
- /api/checklists now returns the merged list (static + DB visible);
  the Summary shape gained origin / visibility / owner_email /
  owner_display_name fields.

i18n: 55 new keys per language (110 total) under
checklisten.tab.mine.*, checklisten.mine.*, checklisten.author.*,
checklisten.detail.* (Bearbeiten/Löschen labels for Slice B). i18n
codegen total: 2621 keys.

Build hygiene: bun run build clean, go build clean, go vet clean,
go test ./internal/... + ./cmd/server/ all green.
2026-05-20 15:24:07 +02:00
mAi
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.
2026-05-20 15:24:06 +02:00
mAi
1c8cdd3079 docs(checklists): t-paliad-225 inventor design — user-authored checklists (#61)
918-line design doc covering all three capabilities from m/paliad#61:
authoring, multi-axis sharing, admin-promotion to global.

Load-bearing premise correction: the issue body claims `paliad.checklists`
is an existing table that gets new columns. It is NOT — checklists today
are static Go data in `internal/checklists/templates.go`. Design
introduces `paliad.checklists` from scratch and keeps the static catalog
as a parallel source via a hybrid catalog read layer.

Schema (mig 112): `paliad.checklists` (owner + visibility enum), `paliad.checklist_shares`
(polymorphic recipient: user/office/partner_unit/project),
`paliad.can_see_checklist` predicate, `paliad.checklist_instances.template_snapshot`
column for instance integrity under template edits.

12 decisions ledgered, all defaulted to (R) per task brief (no AskUserQuestion).
Three slices (A foundation, B sharing+promotion, C gallery+backfill).
2026-05-20 15:24:06 +02:00
mAi
82ecbe3b8e Merge: t-paliad-224 — calendar-view alignment (m/paliad#55)
Three calendar implementations consolidated into one. Custom Views' shape-calendar.ts
becomes the canonical renderer; /events Kalender tab and the orphaned
/deadlines/calendar + /appointments/calendar pages now use the same module.

- frontend/src/client/calendar/mount-calendar.ts — new canon module extracted
  from shape-calendar.ts. Month/week/day, URL state via ?cal_view/?cal_date,
  drill-down day view, kind-coded pills.
- /events Kalender tab folded onto mountCalendar(); the old modal popup
  replaced with day-view drill-down (Q2/(R)).
- /deadlines/calendar + /appointments/calendar become 301 redirects to
  /events?type=…&view=calendar (handlers test added to pin the targets).
- .frist-cal-* CSS block dropped (~180 lines). Dead i18n keys removed.

Net: ~700 LOC removed, ~100 added. Zero schema/endpoint changes. Same data-loader
shared across all surfaces. Single PR per Q7(R).
2026-05-20 15:23:50 +02:00
mAi
badbffa6e0 test(handlers): t-paliad-224 — pin /deadlines/calendar + /appointments/calendar redirect targets
Adds TestStandaloneCalendarHandlers_RedirectToEventsKalender to
internal/handlers/redirects_test.go covering both standalone-
calendar handlers. Each must 301 to the canonical Kalender-tab URL
on /events, preserving the bookmark contract called out in the
handler doc comments. Sister of the existing sub-projects redirect
test.
2026-05-20 15:23:28 +02:00
mAi
0f98d2cd39 refactor(calendar): t-paliad-224 — retire standalone calendar pages + prune dead code
Delete the four orphan files behind /deadlines/calendar +
/appointments/calendar:
- frontend/src/{deadlines,appointments}-calendar.tsx
- frontend/src/client/{deadlines,appointments}-calendar.ts
The standalone pages were unreachable from the UI since t-paliad-110
(Sidebar/BottomNav point at /events?type=…); their only role was as
bookmark targets.

Handlers in internal/handlers/{deadlines,appointments}_pages.go now
301-redirect to /events?type=…&view=calendar so bookmarks still
work. Route registrations in handlers.go remain unchanged — the
gate + redirect pair gives us the same URL surface with one canonical
renderer.

build.ts: drop the renderDeadlinesCalendar / renderAppointmentsCalendar
imports + entry-point bundle paths + dist HTML writes.

frontend/src/client/paliadin-context.ts: drop the two route-key
matches for the standalone URLs (the client never sees those
pathnames any more — 301 fires server-side).

Dead CSS pruned in frontend/src/styles/global.css (~180 lines):
- .frist-calendar, .frist-cal-{controls,month-label,grid,cell,…}
  block (lines 7464-7613 pre-refactor)
- @media (max-width: 700px) { .frist-cal-cell { min-height: 64px; } }
- .termin-cal-legend{,-item}
- .frist-cal-popup-time
- .frist-cal-dot.events-cal-dot-appointment

All verified by grep across frontend/ + internal/ to have no
non-calendar consumers before deletion.

Dead i18n keys removed (DE + EN + i18n-keys.ts union type):
- deadlines.kalender.{title,heading,subtitle,list,today,empty}
- appointments.kalender.{title,heading,subtitle,list,empty}
- deadlines.list.calendar, appointments.list.calendar (button labels
  on the deleted standalone routes)
- events.calendar.empty (replaced by cal.day.no_entries inside
  mountCalendar's day view)

Per head decisions §11 Q1 + Q8 (drop standalone pages as 301s; drop
dead i18n now).

Tests: go build ./... clean; go test ./internal/... 9 packages pass;
cd frontend && bun run build clean (2535 i18n keys); bun test
frontend/src/client/{calendar,views}/ all 73/73 pass.
2026-05-20 15:23:28 +02:00
mAi
d0f732d0ec refactor(events): t-paliad-224 — fold Kalender tab into mountCalendar()
The /events Kalender view now mounts the canonical mountCalendar()
module from frontend/src/client/calendar/ — same renderer Custom
Views uses for shape=calendar. Drops the events-page-specific
month-grid + popup code path entirely.

What replaces what
- renderCalendar() / openCalPopup() / calDotClass / fmtMonthYear /
  isoDate / itemDateISO and the calYear/calMonth module state →
  one mountCalendar() handle (lazy, urlState=true).
- events-cal-prev / events-cal-next / events-cal-today buttons →
  toolbar in mountCalendar (includes its own 'Heute' button).
- modal popup on cell click → drill-down to day view (matches
  /views; head decision §11 Q2).
- @media min-height shrink on .frist-cal-cell → views-calendar-*
  responsive surface (CSS unchanged from /views).

Behavioural deltas vs pre-refactor
- /events Kalender now persists view+anchor in ?cal_view + ?cal_date
  (head decision §11 Q3) — refresh / share-link safe.
- Pills are kind-coded (deadline / appointment) rather than urgency-
  coded; matches /views (head decision §11 Q4 — drop subtype dot
  colouring, file as follow-up).
- Empty-month message gone; the per-day no-entries state from the
  day-view replaces it (head decision §11 Q8 — drop dead i18n).

Adapter: toCalendarItem() preserves the pre-refactor bucketing rule
— deadlines bucket on due_date, appointments on start_at, both fall
back to event_date.

events.tsx: 31-line calendar subtree (toolbar + grid + modal +
empty hint) reduces to a single host div. mountCalendar fills it
when the user picks Kalender.
2026-05-20 15:23:28 +02:00
mAi
e83b150eda refactor(calendar): t-paliad-224 — extract mountCalendar() canon module
Lift the month/week/day renderer out of shape-calendar.ts into a new
frontend/src/client/calendar/mount-calendar.ts module so /events
Kalender (next commit) and Custom Views shape=calendar both go
through the same code path.

shape-calendar.ts becomes a thin adapter (ViewRow → CalendarItem +
defaultView=render.calendar.default_view, urlState=true). The
extracted module adds:

- update(items) on the returned handle so /events can re-mount on
  filter changes without rebuilding state.
- destroy() for clean teardown when /events switches shapes.
- A 'Heute' button in the toolbar (cal.today, DE+EN added to i18n.ts
  + i18n-keys.ts).
- Optional opts.urlPrefix for surfaces that may share a URL with
  another calendar.

mountCalendar reads ?cal_view / ?cal_date when opts.urlState=true.
/events will mount with urlState=true after the next commit so the
Kalender tab + day-view drill remain refresh-stable (per §11 Q3 in
the design doc).

Pure-helper test suite (mount-calendar.test.ts) covers isoDate,
startOfDay, startOfWeek, shift, bucketByDate, filterByDay, isToday —
12 assertions, all green. DOM rendering covered by manual smoke (no
jsdom in this repo's bun test setup; see verfahrensablauf-core.test.
ts comment for the convention).
2026-05-20 15:23:28 +02:00
mAi
2320cb765d docs(design): t-paliad-224 — head accepted all 8 (R) defaults
Decisions section §12 filled in per head msg #2087. Status → ACCEPTED.
Coder shift proceeds on same branch per Q7(R): single PR.
2026-05-20 15:23:28 +02:00
mAi
668558380d docs(design): t-paliad-224 — align calendar views (m/paliad#55)
Audit + refactor plan: three calendar implementations live today —
/events tab, standalone /deadlines|appointments/calendar pages, and
Custom Views shape-calendar.ts. Canonicalise on shape-calendar.ts by
extracting a shared mount-calendar.ts module, fold /events into it,
retire the standalone pages as 301 redirects, delete ~180 lines of
duplicated CSS.

Net: ~700 LOC removed, ~100 added, zero schema/endpoint changes.

8 open questions for head in §11; AskUserQuestion is disabled for this
task per role brief, so head answers via mai instruct and decisions
land in §12.
2026-05-20 15:23:28 +02:00
mAi
9dd47a0591 Merge: t-paliad-223 Slice B — Add User on /admin/team (m/paliad#49)
Completes t-paliad-223 (team & admin surface). Slice A (Project Admin role
+ inheritable role-edit) and Slice C (click-to-select) already merged at
111c7c3.

- SupabaseAdminService + AdminCreateUserFull — auth.users create via the
  Supabase Admin API (requires SUPABASE_SERVICE_ROLE_KEY env, provisioned
  on paliad's Dokploy compose by head 2026-05-20). Best-effort rollback
  on paliad.users insert failure: deletes the auth row to keep state
  clean.
- Welcome email with magic link sent on create when 'Send welcome email'
  checkbox is on (default per Q2).
- POST /api/admin/users/full endpoint, gated on global_admin.
- Frontend modal on /admin/team — 'Add user' button alongside the
  existing 'Invite colleague' / 'Onboard existing' actions.
- i18n keys for the new modal and toast feedback.
- Tests: happy path, duplicate-email refusal, paliad.users insert failure
  with best-effort auth rollback.

t-paliad-223 fully shipped.
2026-05-20 15:20:13 +02:00
mAi
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.
2026-05-20 15:19:48 +02:00
60 changed files with 7455 additions and 1534 deletions

View File

@@ -128,6 +128,20 @@ func main() {
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
// new "Konto direkt anlegen" path on /admin/team. The key is
// optional: when unset the client still wires (so dependents
// don't panic) but every call short-circuits with
// ErrSupabaseAdminUnavailable so the rest of the server stays
// runnable.
supabaseAdminClient := services.LoadSupabaseAdminClient()
if supabaseAdminClient.Enabled() {
log.Println("supabase admin API configured — /admin/team Add-User path active")
} else {
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
}
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet
@@ -137,6 +151,11 @@ func main() {
eventTypeSvc := services.NewEventTypeService(pool, users)
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
sysAuditSvc := services.NewSystemAuditLogService(pool)
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
svcBundle = &handlers.Services{
Project: projectSvc,
Team: teamSvc,
@@ -165,7 +184,11 @@ func main() {
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
ChecklistCatalog: checklistCatalogSvc,
ChecklistTemplate: checklistTemplateSvc,
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
Mail: mailSvc,
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),

View File

@@ -0,0 +1,448 @@
# Design: Align calendar-view rendering between Events/Termine and Custom Views
**Task:** t-paliad-224 — m/paliad#55
**Author:** bohr (inventor)
**Date:** 2026-05-20
**Status:** ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch.
**Branch:** `mai/bohr/calendar-view-align`
---
## 0. Premise check (verified against live source 2026-05-20)
m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two:
| | A — Events tab | B — Standalone | C — Custom Views |
|---|---|---|---|
| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` |
| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) |
| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) |
| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) |
| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage``internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` |
**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context.
The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3.
---
## 1. m's intent (as I read it)
> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!"
The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is:
1. **One calendar component**, mounted from both the events-page surface and the custom-views surface.
2. **Identical visual output** when the same items land in either surface.
3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go.
4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body.
The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3.
---
## 2. What actually diverges today
Side-by-side after reading all three implementations (cited line numbers above):
| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) |
|---|---|---|---|
| Views offered | month only | month only | month + week + day |
| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` |
| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" |
| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded**`views-calendar-pill--{deadline|appointment|project_event|approval_request}` |
| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) |
| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal |
| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) |
| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view |
| Toolbar | inline month-label + Heute button | identical | view-switcher chips (M/W/D) + range-label + (in day/week) "Zurück zum Monat" link |
| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) |
| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) |
| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) |
| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) |
| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup |
| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` |
The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem.
CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away.
---
## 3. Recommended design (TL;DR)
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|---|---|---|
| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. |
| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. |
| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. |
| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type``kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. |
| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. |
| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. |
| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. |
| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. |
**Net code change (estimated by file):**
- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines.
- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state).
- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block).
- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types).
- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim.
- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`).
Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.**
---
## 4. Architecture sketch
```
┌─────────────────────────────┐
│ frontend/src/client/ │
│ calendar/ │
│ mount-calendar.ts ★ │ ← new shared module
│ types.ts (CalendarItem)│
└──────────────┬──────────────┘
┌────────────────────────┼─────────────────────────┐
│ │ │
client/events.ts (Kalender tab) client/views/ │
│ shape-calendar.ts │
│ (thin wrapper) │
│ │ │
│ ▼ │
│ client/views.ts │
│ paintRows(…, "calendar") │
│ │
└──────────────────────────────────────────────────┘
Data flows:
A: /events → fetch /api/events?type=…&status=… → EventListItem[]
→ toCalendarItem(items) → CalendarItem[]
→ mountCalendar(host, items, opts)
C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[]
→ toCalendarItem(rows) (noop-ish: rename typekind already done)
→ renderCalendarShape() → mountCalendar(host, items, opts)
```
### 4.1 The shared module (`mount-calendar.ts`)
```ts
// frontend/src/client/calendar/mount-calendar.ts
import { t, tDyn, getLang, type I18nKey } from "../i18n";
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
event_date: string; // ISO-8601; first 10 chars are yyyy-mm-dd
project_id?: string;
project_title?: string;
project_reference?: string;
}
export interface CalendarOpts {
defaultView?: "month" | "week" | "day";
/** If true, calendar reads/writes ?cal_view + ?cal_date (or the prefixed
* equivalents); if false, state is in-memory only (use for embedded
* calendars where URL state belongs to the host page). */
urlState?: boolean;
/** Optional prefix for URL params (default: empty). Set if more than
* one calendar might live on the same URL. */
urlPrefix?: string;
/** Optional override: how to render a row's href. Default uses the
* kind→/deadlines|/appointments|/inbox|/projects routing the existing
* shape-calendar.ts ships with. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Re-render with a new item set (e.g. after a filter change in /events). */
update(items: CalendarItem[]): void;
/** Tear down listeners + clear host. */
destroy(): void;
}
export function mountCalendar(
host: HTMLElement,
items: CalendarItem[],
opts?: CalendarOpts,
): CalendarHandle;
```
Internals lifted verbatim from `shape-calendar.ts` (toolbar, renderMonth/Week/Day, renderPill, renderRowAnchor, bucketByDate, filterByDay, startOfWeek, shift, isToday, isoDate, formatRangeLabel, formatWeekHeader, readView/Anchor, writeURL). Two tweaks:
- `readView`/`readAnchor`/`writeURL` accept the `urlPrefix` so embedded calendars on `/events?…&` don't clobber other pages' `?cal_view`.
- `urlState: false` skips the URL read/write entirely — initial state comes from `opts.defaultView` and "today".
### 4.2 `shape-calendar.ts` (after refactor)
```ts
import type { RenderSpec, ViewRow } from "./types";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
export function renderCalendarShape(
host: HTMLElement, rows: ViewRow[], render: RenderSpec,
): void {
const items: CalendarItem[] = rows.map(r => ({
kind: r.kind,
id: r.id, title: r.title,
event_date: r.event_date,
project_id: r.project_id,
project_title: r.project_title,
project_reference: r.project_reference,
}));
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
```
### 4.3 `client/events.ts` (calendar arm only)
```ts
// near the top
import { mountCalendar, type CalendarItem, type CalendarHandle } from "./calendar/mount-calendar";
// state
let calendar: CalendarHandle | null = null;
// inside applyView() when switching to calendar view:
function ensureCalendarMounted(host: HTMLElement, items: CalendarItem[]) {
if (calendar) { calendar.update(items); return; }
calendar = mountCalendar(host, items, { urlState: false, defaultView: "month" });
}
// inside applyView() when switching AWAY from calendar:
function teardownCalendar() {
if (calendar) { calendar.destroy(); calendar = null; }
}
function toCalendarItem(it: EventListItem): CalendarItem {
return {
kind: it.type as CalendarKind, // type "deadline" | "appointment"
id: it.id, title: it.title,
event_date: itemDateISO(it) + "T00:00:00",
project_id: it.project_id,
project_title: it.project_title,
project_reference: it.project_reference,
};
}
```
`urlState: false` for /events because the page already owns its own URL contract (`?type=`, `?status=`, etc.) and a second calendar deep-link param set would compete with future events-page state. (See §11 Q3 — this is a defaultable preference, not a hard constraint.)
### 4.4 Standalone calendar redirects
```go
// internal/handlers/deadlines_pages.go
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
}
// internal/handlers/appointments_pages.go
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
}
```
The `view=calendar` query string is a **new** events-page URL contract — needs a one-line addition to `client/events.ts:readURLState()` (which already reads `type`, `status`) to honour `view`. Today the view is in-memory only; pinning it to URL is a free side-benefit of this refactor (and lets the redirects land users on the calendar, not on the cards view).
Build pipeline: delete entries `frontend/build.ts:258`, `261`, `387`, `390` (the two standalone calendar bundles + HTML writes). `paliadin-context.ts:96,100` keep their URL matches — the 301 fires server-side, so the client only ever sees `/events?type=…&view=calendar` (which already maps to a paliadin context).
---
## 5. Visual + interaction parity audit
Walking m's brief checklist against the proposed end-state (assuming the user is on /events Kalender tab after this refactor):
| Brief item | Today (A) | After refactor | Matches /views? |
|---|---|---|---|
| Event tile shape | dot | **pill with text** | ✓ |
| Color | mixed (urgency + single appointment colour) | **kind-coded** (deadline / appointment / project_event / approval_request) | ✓ |
| Click behaviour (navigate to detail) | modal popup → anchor | **direct anchor on pill** (no modal) | ✓ |
| Today highlight | accent circle on day-num | **border ring on entire cell + box-shadow** | ✓ |
| Weekday header | static TSX divs | **rendered inline in the JS grid** | ✓ |
| Date-range / project / type filter shape | same `EventListItem[]` post-adapter | identical adapter feeds same `CalendarItem[]` shape | ✓ shared loader contract |
Two surfaces still differ after the refactor — and that's by design:
1. **/events** still has its three view chips above the calendar (Karten / Liste / Kalender) because the events page is multi-shape at the outer level. /views also has its outer shape chips (Liste / Karten / Kalender / Timeline). Both surfaces' shape chips look identical (`agenda-chip-row`).
2. **/events** keeps the events-page-level filters (type chip, status select, project select, event-type/appointment-type filters) above the calendar; /views shows its filter-bar (filter-spec-driven axes) instead. Both surfaces' filter chrome is governed by the page, not the calendar — the calendar component itself is the same DOM either way.
---
## 6. Mobile parity
`shape-calendar.ts` today does a mobile fallback at <600px (`mountCalendar` would carry this behaviour over). The fallback appends a single `<p>` notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" or equivalent (i18n key `views.calendar.mobile_fallback`). Cells still render and are responsive (the existing CSS uses CSS-grid + 1fr columns).
After this refactor:
- /events Kalender tab: gets the **same** notice + a contextual hint suggesting "Wechsle zu Karten oder Liste" (the events-page shape chips). One new i18n key, OR reuse the existing `views.calendar.mobile_fallback` and accept that it mentions "Listenansicht" generically.
- /views Kalender shape: behaviour unchanged from today.
Mobile audit boxes ticked:
| | Today A | Today B | Today C | After |
|---|---|---|---|---|
| Cell shrinks on narrow viewport | (min-height 64px) | | partial (cells stay 80px) | (carry the C behaviour, plus the @media min-height shrink ported) |
| Touch target size on pills | n/a (dots, not tappable) | n/a | OK (8px+ at 1x) but verify on a real phone during coder smoke | OK |
| Modal vs drill-down | modal (small viewports lose layout) | modal | drill-down (changes URL natural back button) | drill-down across both surfaces |
| Sidebar collision | sidebar collapses to bottom nav under 768px (existing behaviour) | identical | identical | identical |
One coder-time TODO: verify the drill-down day-view is comfortable on mobile (it's a vertical list, should be fine, but worth one Playwright screenshot during smoke).
---
## 7. Tests + smoke
Existing test coverage relevant to this refactor:
- `frontend/src/client/views/shape-timeline-cv.test.ts` sibling of shape-calendar, no calendar-specific tests today. Add `frontend/src/client/calendar/mount-calendar.test.ts` for the extracted module.
- No Go tests touch handler dispatch for `/deadlines/calendar` or `/appointments/calendar` specifically (verified by grep).
- `internal/services/render_spec_test.go` covers `CalendarConfig.validate()` unchanged.
New test plan:
1. **`mount-calendar.test.ts` (new)** table-driven:
- Empty `items[]` month view renders 7-column grid + no pills + (for /views) per-day "no entries" only in day/week views.
- `items[]` with mixed kinds pills get the correct `views-calendar-pill--{kind}` class.
- `?cal_view=week` week column grid renders.
- Today bucket flagged with `--today` class on the correct cell.
- `+N` overflow renders when items per day > MAX_PILLS_PER_MONTH_CELL (3).
- `update(items)` after first mount swaps content without leaking listeners (assert no double-fire on month-nav click).
2. **`client/events.ts`** — light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away calls `destroy()`. No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment.
3. **Smoke (manual, with `bun run build` + dev server)**:
- /events Kalender tab loads, shows pills, click pill navigates to detail.
- Day-num click → day view (URL changes if urlState is on for /events per Q3).
- /views/{slug} with `render_spec.shape=calendar` (need a saved view or temporary system view to exercise) still loads identical pills + drill-down.
- /deadlines/calendar → 301 → /events?type=deadline&view=calendar lands on Kalender tab.
- /appointments/calendar → 301 → /events?type=appointment&view=calendar lands on Kalender tab.
- DE + EN language toggle on both surfaces.
- Light + dark theme on both.
4. **Build gate**: `go build ./... && go test ./internal/... && cd frontend && bun run build` must all be clean (per task brief).
---
## 8. Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| Custom Views users have saved views with `shape=calendar` and rely on the current week/day behaviour | low (shape-calendar is the canonical, only behaviour I'm changing about it is making `urlState` opt-in) | The refactor is structural — same toolbar, same drill-down, same URL params for /views. `urlState=true` stays the default for that surface. |
| `paliadin-context.ts` keys (`deadlines.calendar`, `appointments.calendar`) become unreachable after redirects | low | The 301 fires before the client sees the URL; new URL maps to existing `events` context. If we want to preserve the labels, add `events?type=…&view=calendar` matchers in paliadin-context (one if-branch each) — recommend doing this in the same coder PR for tidiness. |
| Subtype colouring loss is a feature regression for someone who used /appointments/calendar's legend | low | The page is unreachable from the UI; nobody reaches it without a bookmark. Q4 below confirms with m. |
| Events-page calendar `urlState: false` means refresh loses the Kalender chip selection | medium (today: same — calendar is in-memory either way) | Either accept (status quo) or extend events.ts URL state to include `view` (~3 lines). Q3 below. |
| /events fetch is unfiltered by date (loads everything); on a busy team Kalender may load slow | medium (existing behaviour) | Not addressed by this refactor. Filed as follow-up in §10. Filter spec / /api/views path solves it but is out of scope here. |
| The 301 redirect to `/events?type=…&view=calendar` requires events.ts to honour `view=calendar` from the URL | hard requirement | Must include this in the coder PR. ~3 lines in `readURLState()`. |
---
## 9. What stays "out of scope" (consistent with the issue body)
- New calendar UX: drag-to-create, week-resize, hover-preview, multi-day event spans.
- Performance: switching `/events` to a date-window-bounded fetch (today it loads everything and filters client-side).
- A unified events↔views landing (e.g. /events as a Saved View). Discussed in `design-events-unification-2026-05-04.md` and `design-data-display-model-2026-05-06.md`; deliberately not folded in here.
- /agenda surface. It's a timeline-grouped feed, not a calendar grid — separate conversation if m wants to converge it.
- Subtype dot colouring (deferred per §3 trade-off row).
---
## 10. Follow-ups (file as separate issues after this lands)
1. **Date-windowed loading for /events Kalender.** Pass `?from=…&to=…` to `/api/events` matched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already accepts `from`/`to` per `internal/handlers/events.go`. Small.
2. **Per-shape config: subtype colouring.** Add `CalendarConfig.subtype_colors` (bool, default false). Surface a `--subtype-{value}` modifier on the pill so the appointment-type colour key can come back per-view, if a user asks.
3. **Multi-day event spans.** Most events are single-day; deadlines are point-in-time. But appointments have `end_at`. Today neither A nor C surfaces span-rendering. Defer until requested.
4. **/agenda convergence.** /agenda is a different visual (day-grouped feed), but the data shape is the same `EventListItem`. If m wants /agenda to disappear (it's a sibling overview entry today per `design-events-unification-2026-05-04.md`), consider folding it into /events as a fourth shape ("feed" / "agenda"). Out of this design's scope.
---
## 11. Open questions for head (NO AskUserQuestion — answered via mai instruct)
> The role brief disables `AskUserQuestion` for this task. Each question below has a defaulted answer marked **(R)**; head/m can confirm or override via `mai instruct head`. After head replies, decisions land in §12.
**Q1 — Canonical renderer.** Adopt `shape-calendar.ts` as the canon, fold A into it (§3 sketch), and retire the two standalone routes B as 301-redirects to `/events?type=…&view=calendar`?
- **(R) Yes** — covers m's intent ("pick the canonical one — likely the Custom Views renderer"). Net code goes down, no schema changes.
- Alternative: keep the standalone routes as standalone pages but make them call `mountCalendar` internally — adds nothing for users (page is unreachable), wastes a build target each.
- *(answer: yes / keep-standalone / something-else)*
**Q2 — Events-page Kalender tab: drill-down vs modal-popup.** Today /events Kalender opens a modal listing the day's items. After the refactor, clicking a day-num drills into the day view (changes view chip, same URL component, but the page swaps to a day-list). Drop the modal entirely?
- **(R) Drop the modal** — matches /views behaviour, gives a real day-view (not just a list of links), and removes one popup-management code path.
- Alternative: keep the modal on /events only (parity break — defeats the point of the issue).
- *(answer: drop / keep)*
**Q3 — URL state for the /events calendar.** Should the /events Kalender persist its view (month/week/day) and date in the URL via `?cal_view=…&cal_date=…` (matching /views)?
- **(R) Yes, persist** — refresh-stable, shareable, ~3 lines in `readURLState()`. /views does it. Cost is owning the param contract on /events.
- Alternative: in-memory only — today's behaviour. Keeps /events URL surface minimal.
- *(answer: persist / in-memory)*
**Q4 — Subtype dot colouring on appointments.** The orphaned /appointments/calendar today colours dots by appointment_type (Verhandlung / Besprechung / Beratung / Fristverhandlung) with a legend strip. After the refactor pills are kind-coded only (deadline vs appointment vs …). Drop subtype colouring?
- **(R) Drop now, file as follow-up** (§10.2) — page is UI-unreachable today; nobody will notice; can come back as a `CalendarConfig.subtype_colors` flag if/when requested.
- Alternative: preserve subtype colouring on /events Kalender tab as well, with a fresh legend matching the new pill colours.
- *(answer: drop / preserve)*
**Q5 — Mobile fallback text.** /views Kalender shows a notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" (key `views.calendar.mobile_fallback`). Reuse the same key on /events, or add an /events-specific key recommending the events-page "Karten" or "Liste" shape?
- **(R) Reuse the existing key** — generic phrasing covers both surfaces; both have Karten/Liste alternatives.
- Alternative: dedicated key per surface — clearer copy but more strings to maintain.
- *(answer: reuse / dedicated)*
**Q6 — Test approach for the extracted module.** Add `mount-calendar.test.ts` with the seven listed cases (§7.1), OR also add a Playwright smoke that drives the new flow end-to-end through both surfaces?
- **(R) Unit tests + manual smoke gauntlet** — matches the codebase's existing test layout (most client/* tests are unit-level; Playwright is reserved for fewer flows). Manual smoke per §7.3 is the brief's bar.
- Alternative: unit + Playwright.
- *(answer: unit-only / unit-plus-playwright)*
**Q7 — Sequencing across PRs.** One PR (extract + adopt + retire + CSS prune) or three (extract, then adopt+retire, then CSS prune)?
- **(R) One PR** — refactors that don't bisect well are worse split (each intermediate state has unused exports / dead code paths / orphaned CSS classes for a few hours). The diff is reviewable in one read because it's mostly moves + deletes.
- Alternative: three PRs — easier rollback at each step, but you'd have to land #2 before m sees any UI alignment, which loses the point.
- *(answer: one-pr / three-pr)*
**Q8 — When (if at all) to delete /events `events.calendar.empty` i18n key.** Replaced by `cal.day.no_entries` in the new flow. Drop now or leave as a dead key in `i18n-keys.ts` for one release?
- **(R) Drop now** — i18n-keys.ts is the source of truth; dead keys aren't enforced at compile time but they're a slow-rotting maintenance tax. /events' new calendar surface doesn't render an "empty month" message any more (per-day "no entries" is the only empty state, matching /views).
- Alternative: leave for one release as a soft-deprecate.
- *(answer: drop / leave)*
---
## 12. m's decisions (2026-05-20, via head msg #2087)
Head accepted all 8 (R) defaults in one round-trip ("Design accepted in
full — all 8 (R) defaults stand"). Recorded verbatim below; each entry
is the (R) pick from §11.
- **Q1 — Canonical renderer:** Yes. Canonicalise on `shape-calendar.ts`; fold A into it via extracted `mountCalendar()`; retire B as 301 redirects to `/events?type=…&view=calendar`.
- **Q2 — Drill-down vs modal:** Drop the modal on /events. Day-num/+N click drills into the day view, matching /views.
- **Q3 — URL state on /events:** Persist. /events Kalender reads/writes `?cal_view=…&cal_date=…` like /views does. Adds `view=calendar` to `client/events.ts:readURLState()` so refreshes/redirects land on the Kalender tab.
- **Q4 — Subtype dot colouring:** Drop now. Filed as follow-up §10.2. Pills are kind-coded only after the refactor (deadline / appointment / project_event / approval_request).
- **Q5 — Mobile fallback text:** Reuse the existing `views.calendar.mobile_fallback` key on /events as well — generic phrasing covers both surfaces.
- **Q6 — Test approach:** Unit tests (`mount-calendar.test.ts`) + manual smoke gauntlet (§7.3). No Playwright on this refactor.
- **Q7 — Sequencing:** One PR. Extract + adopt + retire + CSS prune land together on `mai/bohr/calendar-view-align`.
- **Q8 — Empty-state i18n key:** Drop dead keys now (`events.calendar.empty`, `appointments.kalender.*`, `deadlines.kalender.*`, appointment-type legend keys not used elsewhere).
---
## 13. Coder hand-off (after m's go on §11)
Once §12 is filled in, the coder shift can proceed in this order:
1. Create `frontend/src/client/calendar/mount-calendar.ts` + `frontend/src/client/calendar/mount-calendar.test.ts`. Lift the shape-calendar internals; add `update`/`destroy` to the returned handle; pipe `urlState` + `urlPrefix` through.
2. Update `frontend/src/client/views/shape-calendar.ts` to delegate to `mountCalendar` (≈30 lines after the lift).
3. Update `frontend/src/client/events.ts`: import `mountCalendar`, replace `renderCalendar`/`openCalPopup` and nav handlers with a `mountCalendar(host, items, { urlState: <per Q3>, defaultView: "month" })` call inside the existing `applyView()` branch. Add the `view=calendar` URL state handling per Q3.
4. Update `frontend/src/events.tsx`: strip the `events-calendar-wrap` inline DOM (toolbar + grid + modal). The empty container `<div id="events-shape-calendar" />` plus a wrapper class is enough — `mountCalendar` builds the DOM.
5. Delete `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts`.
6. Update `frontend/build.ts`: remove the `*-calendar.ts` entry-point lines (≈250s) and the `*-calendar.html` writes (≈387s).
7. Update `internal/handlers/deadlines_pages.go` + `internal/handlers/appointments_pages.go`: turn the two calendar handlers into 301 redirects to `/events?type=…&view=calendar`.
8. Update `frontend/src/styles/global.css`: delete `.frist-cal-*`, `.termin-cal-*`, `.events-cal-dot-appointment`, the 700px-media tweak (lines ~7464-7620, ~8019-8023, ~8680-8700, ~11519-11533). Sanity-check no other consumer (already verified via grep — none).
9. Update i18n: drop `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend keys only — keep type values used elsewhere), `events.calendar.empty` per Q8. Make sure `cal.view.*`, `cal.day.no_entries`, `cal.day.back_to_month`, `cal.day.open_day`, `views.calendar.mobile_fallback` (or a new events-specific key per Q5) all exist DE + EN — most already do.
10. `paliadin-context.ts`: optional one-line addition to map `events?view=calendar` to the new context label.
11. Run `go build ./... && go test ./internal/... && cd frontend && bun run build`.
12. Manual smoke per §7.3.
13. Commit. `mai report completed` with SHA per task brief.
Estimated coder shift: one PR per Q7 (R).
---

View File

@@ -0,0 +1,918 @@
# User-authored checklists: authoring, sharing, admin-promotion
**Task:** t-paliad-225 — Gitea m/paliad#61
**Inventor:** dirac, 2026-05-20
**Branch:** `mai/dirac/user-checklists`
**Status:** DESIGN READY FOR REVIEW
## 1. Problem statement
Paliad ships a curated catalog of UPC / DE / EPA checklists today
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
on Akten and check items off; per-instance state lives in
`paliad.checklist_instances` and is gated by the parent project's
team-based visibility.
m wants three new capabilities (m 2026-05-20 14:14):
1. **User-authored templates** — any non-`global_admin` can create a
checklist template they own (title, sections, items, references).
2. **Sharing** — author shares with specific colleagues, an Office, a
Dezernat (partner-unit), a project team, or the whole firm.
3. **Admin promotion to global**`global_admin` promotes an authored
template into the firm-wide catalog so it appears alongside the
curated UPC/DE/EPA templates for every user.
This design covers all three across three sequential slices.
## 2. Premises verified live (load-bearing findings)
The Gitea issue body says "Add `owner_id uuid NULL` to
`paliad.checklists`". That table **does not exist**. Verifying against
the live DB and the code corrected several premises:
- **`paliad.checklists` does NOT exist as a DB table.** Templates today
are pure Go data in `internal/checklists/templates.go` (6 entries,
~310 lines), served by `internal/handlers/checklists.go` via
`checklists.Summaries()` and `checklists.Find(slug)`. The DB has
`paliad.checklist_instances` (per-user state) and
`paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
design has to introduce `paliad.checklists` from scratch.
- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
validity is enforced in `ChecklistInstanceService.Create` against the
static Go registry. This is what lets the design keep the static
catalog as one source of truth and add the DB catalog as a parallel
source: instance creation just resolves the slug against the merged
view and snapshots the template body.
- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
109 user_dashboard_layouts, 110 project_type_other, 111
project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
today). At inventor time the next free slot is **112**. The coder
MUST re-verify with `ls internal/db/migrations/ | tail` at shift
start — the slot can drift if other branches merge first.
- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
branches on global_admin shortcut + project_teams responsibility =
'admin'. **Used by this design** to gate the "Make global" button (we
reuse the global_admin shortcut, not the project-admin branch — see
§4.4) and as the precedent for any new STABLE SECURITY DEFINER
predicates we add.
- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
Columns: `event_type` (free-text), `actor_id`, `actor_email`,
`scope` ∈ {org, project, personal}, `scope_root uuid`,
`metadata jsonb`. RLS: self-read for the actor +
global_admin read-all. **Pattern to follow:** insert event row at
state transition (see `ExportService.WriteAuditRow` in
`internal/services/export_service.go:1120` for the canonical shape).
- **`paliad.project_events`** is the project-timeline audit sink and is
already wired for checklist instance lifecycle events
(`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
`_deleted`). We do NOT need to invent a new event_type for instance
events; we'll add a few `_snapshot_taken` / template-level events to
`system_audit_log` and keep instance events on `project_events`.
- **`paliad.users.office`** is `text` (CHECK against the office key
list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
have `additional_offices text[]`. Both are first-class columns; no
separate `offices` table.
- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
timestamps) is the Dezernat / practice-group table. Membership lives
in `paliad.partner_unit_members`. Projects attach via
`paliad.project_partner_units` (with derivation flags). All three
are referenceable from a share recipient.
- **`paliad.users.global_role`** is `text`; values include
`'global_admin'`. Used for the firm-wide promote/demote authority.
- **`paliad.project_teams`** (mig 111 just added) carries
`responsibility` ∈ {admin, lead, member, observer, external}. We
reuse `can_see_project` (visibility) for share-to-project recipients,
NOT `effective_project_admin`. The semantic of "share with a project
team" is "anyone on the matter sees it", not "anyone who can edit
membership sees it".
- **No precedent for entity-level sharing in paliad.** The personal-
sidecar tables (`user_views`, `user_dashboard_layouts`,
`user_pinned_projects`, `user_card_layouts`) are owner-only with no
share columns. Existing visibility predicates
(`paliad.can_see_project`) walk the project tree, not arbitrary
entities. This design introduces the first multi-axis share pattern
in the codebase (§3.2).
## 3. Architecture: hybrid templates + share table
### 3.1 Two template sources, one read layer
**KEEP** the static Go template registry as the firm's curated catalog.
It's version-controlled, code-reviewed, immutable at runtime, and the
right substrate for legally-curated content (RoP citations, EPC rule
references). Migrating those into DB rows would lose the git review
trail for content that requires lawyer eyes.
**ADD** `paliad.checklists` as the DB catalog for user-authored content.
Same Template shape (slug, titles, regime, court, groups[], items[])
but stored as JSONB so the schema doesn't have to chase content
evolution.
A `ChecklistCatalogService` unifies the two at read time:
- `ListVisible(user)` → static templates DB rows the user can see
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
rejected if they collide with a static slug).
Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
their JSON shape — they just delegate to the catalog service instead of
the bare static registry.
### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred
The task brief asks for a "modular / abstract" solution. I considered a
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
recipient_*)` table that could later carry shares for views, dashboards,
saved searches, project templates, etc.
**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
v1.** Reasons:
1. There is NO second entity in paliad that requests sharing today —
`user_views`, `user_dashboard_layouts`, `user_card_layouts`,
`user_pinned_projects` are all explicitly owner-only by design (see
migration comments). The "future reuse" is hypothetical.
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
needs its own deletion trigger. That complexity is real, the
reusability gain is not.
3. The CORRECT abstraction emerges by extracting *after* the second use
case shows up. Right now we don't know whether dashboards want the
same recipient axes (user / office / partner-unit / project) or a
different set (e.g. dashboards probably want "everyone on a project"
not "the whole firm").
The design IS modular in the sense that the recipient resolution logic
(below) is centralized in one SQL predicate (§4.3) which a future
polymorphic refactor can lift verbatim.
If the second entity asks for sharing within ~3 months, refactor to
`paliad.entity_shares` as a single-mig follow-up. Until then,
`paliad.checklist_shares` keeps the schema honest.
### 3.3 Visibility states
`paliad.checklists.visibility text` (CHECK enum):
| state | who sees | who edits |
|-----------|----------------------------------------------------|---------------------|
| `private` | owner only | owner |
| `shared` | owner + explicit recipients in checklist_shares | owner |
| `firm` | owner + every authenticated paliad user | owner |
| `global` | owner + every authenticated paliad user + catalog | owner + global_admin|
`firm` vs `global` distinction:
- `firm` = author self-published. Author can flip back to private/shared
any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
- `global` = admin-promoted into the firm catalog. Appears in the main
Vorlagen tab alongside the static templates. Author retains edit
authority by default; only `global_admin` can demote.
Demotion target: `global → firm` (preserves visibility for users who
already started instances). Author can subsequently narrow further.
### 3.4 Template snapshot on instance create
m's brief calls this out as a design decision: when an author edits a
template, do existing instances pick up the changes (propagate) or stay
on the version they were created from (snapshot)?
**Pick: snapshot.** Inventor pick (R). Rationale:
1. **Data integrity.** Instances are working artefacts. A user halfway
through a Klageerwiderung instance shouldn't have items disappear or
reorder under them because the author edited the template.
2. **Audit story.** The completed instance shows exactly what the
author saw when they started. Reconstruction without git-blame on
the template.
3. **Visibility narrowing safe by construction.** If author unshares
from a colleague who already has an instance, the instance survives
because the snapshot is local.
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
exceed a few per user per template. Even 10× the row size of today
is fine.
Schema cost: one nullable `template_snapshot jsonb` column on
`paliad.checklist_instances`. Backfilled lazily existing instances
keep `NULL`, service falls back to looking the slug up in the catalog;
new instances always get a snapshot. Slice C can backfill the column
for already-existing rows via a one-off `UPDATE` if we want strict
consistency.
## 4. Schema (migration 112 — verify slot at coder shift)
Single migration file `internal/db/migrations/112_user_checklists.up.sql`
+ matching `.down.sql`. Idempotent throughout
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).
> Slot caveat: at design time, latest disk = 111, live tracker = 106
> (mig 107-111 pending deploy). Coder MUST re-verify
> `ls internal/db/migrations/ | tail` at shift start. If a higher
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
> 112), bump to the next free slot.
### 4.1 `paliad.checklists` — authored template catalog
```sql
CREATE TABLE paliad.checklists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
-- Authoring metadata
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
regime text NOT NULL DEFAULT 'OTHER', -- UPC | DE | EPA | OTHER
court text NOT NULL DEFAULT '',
reference text NOT NULL DEFAULT '',
deadline text NOT NULL DEFAULT '',
lang text NOT NULL DEFAULT 'de', -- 'de' | 'en' — author's primary language
-- Body
body jsonb NOT NULL, -- { groups: [{ title, items: [{ label, note, rule }] }] }
-- Lifecycle
visibility text NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
promoted_at timestamptz, -- set on transition to 'global'
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- Timestamps
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX checklists_owner_idx ON paliad.checklists (owner_id);
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
CREATE INDEX checklists_regime_idx ON paliad.checklists (regime);
```
**Slug-collision safety net:** application layer validates that the
chosen slug doesn't collide with a static template slug. The static
list is loaded into a `map[string]bool` at boot. New authored slugs
auto-prefixed with `u-` so collisions with static slugs are structurally
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).
### 4.2 `paliad.checklist_shares` — explicit grants
```sql
CREATE TABLE paliad.checklist_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
recipient_kind text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
recipient_office text,
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
-- XOR check: exactly one recipient_* column populated per kind
CONSTRAINT checklist_shares_recipient_xor CHECK (
(recipient_kind = 'user' AND recipient_user_id IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'office' AND recipient_office IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'project' AND recipient_project_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
)
);
-- Avoid duplicates per recipient
CREATE UNIQUE INDEX checklist_shares_user_uniq ON paliad.checklist_shares (checklist_id, recipient_user_id) WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX checklist_shares_office_uniq ON paliad.checklist_shares (checklist_id, recipient_office) WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX checklist_shares_project_uniq ON paliad.checklist_shares (checklist_id, recipient_project_id) WHERE recipient_kind = 'project';
-- Hot-path index for the visibility predicate
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
```
### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate
```sql
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner can always see
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.owner_id = _user_id
)
-- 'firm' / 'global' visible to all authenticated users
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.visibility IN ('firm', 'global')
)
-- Explicit share: user
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = _user_id
)
-- Explicit share: office (matches user.office OR additional_offices)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
-- Explicit share: partner_unit (caller is a member)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'partner_unit'
)
-- Explicit share: project (caller can see the project via existing predicate)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'project'
AND paliad.can_see_project(s.recipient_project_id) -- reuses ltree walk
);
$$;
```
> Note on `can_see_project` self-reference: that function reads
> `auth.uid()` internally — when called from inside another SECURITY
> DEFINER body it picks up the caller's uid via search_path inheritance
> (same pattern as `effective_project_admin` reuse in mig 111).
### 4.4 RLS on `paliad.checklists`
```sql
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
-- SELECT: owner OR visible via can_see_checklist
CREATE POLICY checklists_select
ON paliad.checklists FOR SELECT TO authenticated
USING (paliad.can_see_checklist(auth.uid(), id));
-- INSERT: caller can only create templates owned by themselves
CREATE POLICY checklists_insert
ON paliad.checklists FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
CREATE POLICY checklists_update
ON paliad.checklists FOR UPDATE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
)
WITH CHECK (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- DELETE: owner OR global_admin
CREATE POLICY checklists_delete
ON paliad.checklists FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
```
### 4.5 RLS on `paliad.checklist_shares`
```sql
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
CREATE POLICY checklist_shares_select
ON paliad.checklist_shares FOR SELECT TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- INSERT: only the checklist owner can grant
CREATE POLICY checklist_shares_insert
ON paliad.checklist_shares FOR INSERT TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
AND granted_by = auth.uid()
);
-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
CREATE POLICY checklist_shares_delete
ON paliad.checklist_shares FOR DELETE TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
```
### 4.6 `paliad.checklist_instances.template_snapshot jsonb`
```sql
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
```
Existing RLS on `checklist_instances` untouched.
## 5. Service layer
### 5.1 `internal/services/checklist_catalog_service.go` (new)
Unified read facade over static + DB templates.
```go
type ChecklistCatalogService struct {
db *sqlx.DB
}
type CatalogEntry struct {
Slug string // matches checklists.Template.Slug or paliad.checklists.slug
Origin string // "static" | "authored"
OwnerID *uuid.UUID // nil for static
OwnerName string // empty for static
Visibility string // "static" | "private" | "shared" | "firm" | "global"
Template checklists.Template
}
// ListVisible returns every catalog entry the caller can see.
// Static entries are always returned. DB entries pass through RLS.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)
// Find returns one entry by slug (static lookup first, then DB).
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)
// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
```
### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)
CRUD on `paliad.checklists`.
```go
type ChecklistTemplateService struct {
db *sqlx.DB
users *UserService
}
type CreateTemplateInput struct {
Title string
Description string
Regime string
Court string
Reference string
Deadline string
Lang string
Body checklists.Template // unmarshalled to body jsonb minus slug/titles/etc
}
func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error // private/firm only
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
```
Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
suffix (collision retry up to 3x). Validator enforces
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
`internal/checklists/checklists.go` Templates rejected at write time.
### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)
```go
type ChecklistShareService struct { db *sqlx.DB }
type ShareGrantInput struct {
RecipientKind string
UserID *uuid.UUID
Office string
PartnerUnitID *uuid.UUID
ProjectID *uuid.UUID
}
func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
```
### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)
`global_admin`-only operations.
```go
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }
func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
```
Promote: assert caller.global_role = 'global_admin' UPDATE visibility =
'global', promoted_at = now(), promoted_by = caller audit row
`event_type='checklist.promoted_global'`.
Demote: assert caller is global_admin UPDATE visibility = target
(default 'firm') audit row `event_type='checklist.demoted'`.
### 5.5 Wire instance create to take snapshot
`ChecklistInstanceService.Create` extends to capture
`template_snapshot` at insert time via
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
(NULL snapshot, fallback path in read layer).
### 5.6 Endpoints
| Method | Path | Slice | Purpose |
|--------|------|-------|---------|
| `GET` | `/api/checklists` | (existing)| Merged catalog list (static + visible DB) |
| `GET` | `/api/checklists/{slug}` | (existing)| Single template (static or DB) |
| `POST` | `/api/checklists/templates` | A | Create authored template |
| `GET` | `/api/checklists/templates/mine` | A | List own authored templates |
| `PATCH` | `/api/checklists/templates/{slug}` | A | Edit authored template |
| `DELETE` | `/api/checklists/templates/{slug}` | A | Delete authored template |
| `PATCH` | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
| `GET` | `/api/checklists/templates/{slug}/shares` | B | List grants |
| `POST` | `/api/checklists/templates/{slug}/shares` | B | Grant share |
| `DELETE` | `/api/checklists/shares/{id}` | B | Revoke share |
| `POST` | `/api/admin/checklists/{slug}/promote` | B | Admin promote to global |
| `POST` | `/api/admin/checklists/{slug}/demote` | B | Admin demote |
| `GET` | `/api/checklists/gallery` | C | Browse all firm + global templates |
## 6. Instance snapshot lifecycle
**On Create (`ChecklistInstanceService.Create`):**
1. Resolve slug via `catalog.Find(userID, slug)` enforces visibility.
2. `snapshot = catalog.SnapshotBody(userID, slug)` captures the
template body (groups + items) at this moment, as JSONB.
3. Insert into `checklist_instances` with
`template_snapshot = snapshot`, `template_slug = slug`,
`state = '{}'::jsonb`.
**On Read (`ChecklistInstanceService.GetByID`):**
- Return the instance with `template_snapshot` if non-null.
- If NULL (legacy row created before mig 112), fall back to
`catalog.Find(slug)`. Logged at INFO; not a fatal path.
**On Template Edit (Slice A):**
- Owner edits template via PATCH DB row mutated `checklists.updated_at`
bumped no propagation. Existing instances continue rendering their
snapshot. New instances pick up the edit.
- Audit row `event_type='checklist.edited'`,
`metadata={ checklist_id, slug, changes:[...] }`.
**On Template Delete:**
- DB row deleted. Instances that snapshotted survive (snapshot is
local). Instances that DIDN'T snapshot (NULL) gracefully degrade
service detects "template not found in catalog" and returns the
instance with a sentinel "template withdrawn" body (renders a small
banner client-side; checkboxes still work because `state` is the
source of truth, not the template).
**On Visibility Narrow (firm → shared → private):**
- Existing instances unaffected (snapshot is local; visibility check is
on the template, not instance).
- New instance attempts fail with `ErrNotVisible` (the user can no
longer see the template to instantiate it).
## 7. Frontend (concise sketch — coder owns the detail)
### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"
Add a third tab between "Vorlagen" and "Vorhandene Instanzen":
```
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
```
- **Vorlagen** (existing): static catalog + global-promoted DB
templates, grouped by Regime, filter pills (UPC/DE/EPA).
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
Vorlage" CTA. Each card shows title, description, visibility chip,
Aktions-Buttons (Bearbeiten / Teilen / Löschen).
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
optionally render an "📌 Snapshot" badge when `template_snapshot` is
non-null (Slice A backfill marker).
Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
templates not yet promoted discovery surface).
### 7.2 `/checklists/new` (NEW — Slice A)
Authoring wizard. Three steps:
1. Metadata title, description, regime (UPC/DE/EPA/OTHER), court,
reference, deadline.
2. Sections + items repeating editor (group title items[] of
{label, note, rule}).
3. Visibility radio: privat / firm-weit. (Sharing flow comes in
Slice B.)
Save POST `/api/checklists/templates` redirect to
`/checklists/{slug}` detail.
### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)
Same wizard, prefilled. Owner-only (404 otherwise).
### 7.4 `/checklists/{slug}` detail page
Existing detail page renders the template (static OR authored).
Additions:
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
entfernen" button (Slice B).
- Provenance line under the title: "Erstellt von <author> · <date>"
(only for DB templates).
### 7.5 Share modal (Slice B)
Triggered by "Teilen" on owner's detail page. Four pickers stacked:
- Kollegen (user-picker, multi-select)
- Office (chip-select from `offices.All`)
- Dezernat (chip-select from `partner_units`)
- Projekt (autocomplete from owner-visible projects)
Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
"firm-weit" greys out the picker (firm-weit doesn't need grants).
Apply → POST grants individually → audit emits one
`event_type='checklist.shared'` per grant with
`metadata={ recipient_kind, recipient_id, checklist_id }`.
### 7.6 i18n keys
~28 new keys (DE+EN) under `checklisten.authoring.*`,
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
matches existing `checklisten.tab.*` / `checklisten.instances.*`.
## 8. Audit events
Org-scope (`paliad.system_audit_log` via a small new helper
`SystemAuditLogService.WriteChecklistEvent`):
| event_type | actor | metadata keys |
|----------------------------------|-------------|----------------------------------------------------|
| `checklist.authored` | owner | checklist_id, slug, visibility |
| `checklist.edited` | owner | checklist_id, slug, changed_fields[] |
| `checklist.visibility_changed` | owner | checklist_id, slug, from, to |
| `checklist.shared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.unshared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.promoted_global` | global_admin| checklist_id, slug, owner_id |
| `checklist.demoted` | global_admin| checklist_id, slug, target_visibility |
| `checklist.deleted` | owner OR ga | checklist_id, slug, was_visibility |
Project-scope (`paliad.project_events` — existing helper
`insertProjectEventWithMeta`): existing checklist-instance events
unchanged. NO new project_events types for templates — templates are
not project-scoped.
`AuditService.ListEntries` already reads from `system_audit_log` via
the UNION ALL branch added in t-paliad-214 — no changes needed there;
new event_types surface automatically in the audit log UI.
## 9. Slice plan
### Slice A — Foundation (~700 LoC)
**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
no share table yet; visibility limited to private/firm.
**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
`SystemAuditLogService.WriteChecklistEvent` helper.
**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.
**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
`/checklists/{slug}/edit`, owner controls on detail page.
**Test pass:** unit tests for slug validation, snapshot capture,
visibility predicate (without share rows), audit emit, fallback to
catalog when snapshot NULL.
**No share, no admin promote, no gallery.** Ships immediately useful
for solo authoring + firm-wide publishing.
### Slice B — Sharing + Promotion (~600 LoC)
**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
sub-enum (Slice A schema already includes 'shared' as valid value —
just no grants point at it yet).
**Service:** `ChecklistShareService`, `ChecklistPromotionService`.
**Endpoints:** shares endpoints + admin promote/demote.
**Frontend:** Share modal, "Make global" admin button on detail page,
share-grant chip list on detail page (owner-only).
**Audit:** new event_types (shared, unshared, promoted_global, demoted).
### Slice C — Discoverability + UX polish (~400 LoC)
**Gallery page** `/checklists/gallery`: browses every template the user
can see that's NOT their own, grouped by Regime / Author / Recency.
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.
**Backfill** existing `checklist_instances` with `template_snapshot`
via a one-off migration (mig 114) — pure data move, no schema change.
After backfill, the catalog-fallback path can be removed (deferred to
Slice D / cleanup).
**Optional**:
- "Vorlage kopieren" action — clone an existing template (static OR
authored) into the caller's "Meine Vorlagen" for personal adaptation.
- Per-template instance counter ("12 Kollegen haben diese Vorlage
benutzt") — surfaced from `checklist_instances` group-by.
## 10. Trade-offs flagged
1. **Hybrid catalog (static + DB).** Two sources of truth means two
slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
reserved-list rejection. Refactoring all static templates into DB
loses the git review trail; the hybrid is the right cost.
2. **Polymorphism deferred.** A future second sharable entity will need
to either copy the `checklist_shares` pattern (cheap but duplicative)
or refactor to `entity_shares` (one mig). The refactor is small;
premature abstraction now would pay complexity for no current
benefit.
3. **Snapshot semantics may surprise.** A user who edits their template
expecting downstream instances to update will be confused.
Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
detail page that re-snapshots from the current template (preserves
the user's checkbox state to the extent items still match).
4. **Office membership is set-membership, not hierarchy.** Sharing to
"munich" reaches every user with `office='munich'` OR
`'munich' = ANY(additional_offices)`. There's no concept of "Munich
plus its sub-teams" because offices don't nest in paliad. Fine.
5. **Partner-unit membership join is N+1 on the predicate.** Each
visibility check touches `partner_unit_members` if any partner-unit
share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
already exist (per mig 027 lineage); the join is single-row.
6. **Share-to-project recipient resolution uses
`can_see_project(s.recipient_project_id)`.** That predicate reads
`auth.uid()` from the session, so it works correctly inside our
SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
in `paliad.can_see_project` source — same pattern that
`effective_project_admin` uses in mig 111.
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
Means a global_admin can edit content of any user's template, not
just visibility. This is intentional for catalog hygiene
(correcting typos, removing inflammatory content) but should be used
sparingly and audited. The audit log captures every
global_admin-attributed edit via `checklist.edited` with actor_id.
8. **Instance snapshot fallback path lives indefinitely.** Existing
pre-mig-112 instances stay NULL until Slice C backfills. The
fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
no hot-path concern — but it's "dead code" once the backfill runs.
Acceptable until Slice C.
9. **Cascade on owner deletion.** If an authored template's owner is
removed (`paliad.users.id` cascades), the template is wiped along
with all its shares. Existing instances survive via snapshot. The
alternative (transfer ownership to global_admin on user-delete) is
more polite but introduces governance questions ("which admin?")
that aren't worth Slice A complexity. Flag for Slice C if it bites.
10. **Slug uniqueness across origins enforced application-side.**
The static catalog is in-memory at boot. If a deploy adds a static
slug that collides with an existing DB slug, the deploy boots
cleanly but the DB row becomes unreachable via the catalog read
layer (static wins on slug lookup). Mitigation: a boot-time
integrity check in `cmd/server/main.go` logs WARN if collision
detected. Owner can rename their template manually via the edit UI.
## 11. m's decisions ledger (all defaulted to (R) per task brief)
Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
material." I have not escalated; all picks below default to (R).
| # | Question | (R) pick |
|---|---------------------------------------------------------|-------------------------------------------|
| 1 | Storage model for authored templates | Hybrid: keep static catalog + new `paliad.checklists` DB table |
| 2 | Instance lifecycle on template edit | **Snapshot** at instance create (NOT propagate) |
| 3 | Visibility enum values | `private`, `shared`, `firm`, `global` |
| 4 | Share recipients | user, office, partner_unit, project (4 axes) |
| 5 | Share-to-project resolution | Reuse `can_see_project` (visibility, not just team rows) |
| 6 | Promotion authority | `global_admin` only (no per-project admin promote in v1) |
| 7 | Demotion target | `global → firm` (preserves visibility for in-flight instances) |
| 8 | Slug strategy | `u-` prefix on authored, application-side collision check vs static |
| 9 | Polymorphic share table (`entity_shares`) vs scoped | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
| 10| Authoring i18n | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
| 11| Audit sink for template lifecycle | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
| 12| Slice ordering | A (foundation) → B (share + promote) → C (gallery + backfill) |
Material escalation list: empty. If m disagrees with any of the above,
amend §11 in the next inventor shift; the schema is designed to be
forward-compatible with most reversals (e.g. flipping snapshot →
propagate is a service-layer change, not a schema change).
## 12. Acceptance criteria — Slice A
1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
`paliad` schema).
2. **`/api/checklists` returns merged catalog** — static templates
plus DB templates the caller can see (visibility ∈ {firm, global}
OR owner = caller).
3. **POST `/api/checklists/templates`** creates a row, returns the
created template with auto-generated `u-…` slug, emits
`checklist.authored` audit row.
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
fields, rejects 403 from non-owner non-admin, emits
`checklist.edited`.
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
private↔firm; rejects `shared` and `global` in Slice A (those land
in Slice B); emits `checklist.visibility_changed`.
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
existing instances survive via snapshot.
7. **Instance create snapshots the template body**
`template_snapshot` non-null on every new instance row.
8. **Legacy instances (NULL snapshot) still render** via catalog
fallback (covered by a regression test).
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
CTA navigates to `/checklists/new`; wizard saves successfully.
10. **`go build ./... && go vet ./... && go test ./internal/...`
clean.** `bun run build` clean (i18n key count incremented by ~20).
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
template; setting visibility to `firm` makes it visible to a second
tester account; deleting the template doesn't break existing
instances.
## 13. Recommended implementer
Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
directive 2026-05-06). Substrate is well-trodden:
- Migration shape mirrors mig 111 (gauss) for the predicate function +
policy replacement pattern.
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
emit + visibility check.
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
- Frontend tab pattern mirrors the existing
`entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.
Novel pieces:
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
prototype before committing to the full slice. Pure function; easy
to unit-test.
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
into a STABLE SECURITY DEFINER function; pattern matches mig 111
exactly.
Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
or one branch with three commits — coder's call. Each slice ends with
acceptance criteria; head merges between slices for fast feedback.
## 14. Out of scope (explicitly)
- Importing checklists from external sources (Notion, Trello, .docx).
- Approval-policy gating on checklist edits (admin pre-publish review).
- Cross-firm template marketplace.
- Translation workflow (de↔en) for authored templates — Slice A
ships single-language; if firm appetite shows up post-launch, file
a follow-up.
- Static-catalog editor UI (the static templates remain code-only).
- Versioning UI ("show me the version this instance was created from")
— snapshot is captured; surfacing it is Slice C polish.
---
**Inventor parked per gate protocol.** No auto-shift to coder. Head
decides: same worker as `/mai-coder` with this brief, fresh coder, or
rescope. Slice ordering A → B → C is independent enough that the head
can also greenlight Slice A alone and re-design B/C after Slice A
ships.

View File

@@ -10,6 +10,7 @@ import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklists } from "./src/checklists";
import { renderChecklistsAuthor } from "./src/checklists-author";
import { renderChecklistsDetail } from "./src/checklists-detail";
import { renderChecklistsInstance } from "./src/checklists-instance";
import { renderCourts } from "./src/courts";
@@ -20,10 +21,8 @@ import { renderProjectsChart } from "./src/projects-chart";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
import { renderAppointmentsNew } from "./src/appointments-new";
import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
import { renderAgenda } from "./src/agenda";
@@ -245,6 +244,7 @@ async function build() {
join(import.meta.dir, "src/client/glossary.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklists.ts"),
join(import.meta.dir, "src/client/checklists-author.ts"),
join(import.meta.dir, "src/client/checklists-detail.ts"),
join(import.meta.dir, "src/client/checklists-instance.ts"),
join(import.meta.dir, "src/client/courts.ts"),
@@ -255,10 +255,8 @@ async function build() {
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
join(import.meta.dir, "src/client/appointments-new.ts"),
join(import.meta.dir, "src/client/appointments-detail.ts"),
join(import.meta.dir, "src/client/appointments-calendar.ts"),
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
@@ -370,6 +368,7 @@ async function build() {
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
await Bun.write(join(DIST, "checklists-author.html"), renderChecklistsAuthor());
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
await Bun.write(join(DIST, "courts.html"), renderCourts());
@@ -384,10 +383,8 @@ async function build() {
await Bun.write(join(DIST, "events.html"), renderEvents());
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());

View File

@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
Konto direkt anlegen
</button>
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
Bestehendes Konto onboarden
</button>
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
</div>
</div>
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
Creates BOTH the auth.users row (via Supabase Admin API) and
the paliad.users row in one click. New user is visible in
dropdowns immediately. */}
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erh&auml;lt eine E-Mail mit einem Link, &uuml;ber den sie ein Passwort setzt.
</p>
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
<input type="text" id="admin-af-name" name="display_name" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
<select id="admin-af-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
<select id="admin-af-profession" name="profession">
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
</select>
</div>
<div className="form-field">
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
<select id="admin-af-lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
<label className="form-checkbox">
<input type="checkbox" id="admin-af-send-welcome" checked />
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
</label>
<div id="admin-af-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-team.js"></script>

View File

@@ -1,103 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsCalendar(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="appointments.kalender.title">Terminkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="appointments.kalender.heading">Terminkalender</h1>
<p className="tool-subtitle" data-i18n="appointments.kalender.subtitle">
Monats&uuml;bersicht aller Termine.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="termin-cal-legend">
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-hearing" />
<span data-i18n="appointments.type.hearing">Verhandlung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-meeting" />
<span data-i18n="appointments.type.meeting">Besprechung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-consultation" />
<span data-i18n="appointments.type.consultation">Beratung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-deadline_hearing" />
<span data-i18n="appointments.type.deadline_hearing">Fristverhandlung</span>
</span>
</div>
<div className="frist-calendar" id="appointment-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="appointment-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="appointment-cal-empty" style="display:none" data-i18n="appointments.kalender.empty">
Keine Termine im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/appointments-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,120 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Authoring wizard for paliad.checklists. Both /checklists/new and
// /checklists/templates/{slug}/edit serve this same bundle; the client reads
// window.location.pathname to decide create vs edit mode.
export function renderChecklistsAuthor(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="checklisten.author.title">Vorlage erstellen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklists" />
<BottomNav currentPath="/checklists" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 id="author-heading" data-i18n="checklisten.author.heading.new">Neue Checklisten-Vorlage</h1>
<p className="tool-subtitle" data-i18n="checklisten.author.subtitle">
Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten.
</p>
</div>
<form id="author-form" className="form-stack" autoComplete="off">
<div className="form-row">
<label className="form-label" htmlFor="title" data-i18n="checklisten.author.field.title">Titel</label>
<input className="form-input" id="title" name="title" type="text" required maxLength="200" />
<p className="form-hint" data-i18n="checklisten.author.field.title.hint">z.B. &bdquo;UPC SoC &mdash; interne Checkliste&ldquo;.</p>
</div>
<div className="form-row">
<label className="form-label" htmlFor="description" data-i18n="checklisten.author.field.description">Kurzbeschreibung</label>
<textarea className="form-input" id="description" name="description" rows="3" maxLength="2000" />
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="regime" data-i18n="checklisten.author.field.regime">Regime</label>
<select className="form-input" id="regime" name="regime">
<option value="UPC">UPC</option>
<option value="DE">DE</option>
<option value="EPA">EPA</option>
<option value="OTHER" selected>OTHER</option>
</select>
</div>
<div className="form-row">
<label className="form-label" htmlFor="lang" data-i18n="checklisten.author.field.lang">Sprache</label>
<select className="form-input" id="lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="court" data-i18n="checklisten.author.field.court">Gericht / Beh&ouml;rde</label>
<input className="form-input" id="court" name="court" type="text" maxLength="200" />
</div>
<div className="form-row">
<label className="form-label" htmlFor="reference" data-i18n="checklisten.author.field.reference">Rechtsgrundlage</label>
<input className="form-input" id="reference" name="reference" type="text" maxLength="200" />
</div>
</div>
<div className="form-row">
<label className="form-label" htmlFor="deadline" data-i18n="checklisten.author.field.deadline">Deadline (optional)</label>
<input className="form-input" id="deadline" name="deadline" type="text" maxLength="200" />
</div>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.field.visibility">Sichtbarkeit</legend>
<label className="form-radio">
<input type="radio" name="visibility" value="private" checked />
<span><strong data-i18n="checklisten.mine.visibility.private">Privat</strong> &mdash; <span data-i18n="checklisten.author.visibility.private.hint">Nur f&uuml;r Sie sichtbar.</span></span>
</label>
<label className="form-radio">
<input type="radio" name="visibility" value="firm" />
<span><strong data-i18n="checklisten.mine.visibility.firm">Firmenweit</strong> &mdash; <span data-i18n="checklisten.author.visibility.firm.hint">F&uuml;r alle angemeldeten Kolleginnen und Kollegen sichtbar.</span></span>
</label>
</fieldset>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.groups.heading">Sektionen und Punkte</legend>
<div id="groups-container" />
<button type="button" className="btn btn-secondary" id="add-group" data-i18n="checklisten.author.groups.add">+ Sektion hinzuf&uuml;gen</button>
</fieldset>
<p id="author-error" className="form-error" style="display:none" role="alert" />
<div className="form-actions">
<button type="submit" className="btn btn-primary" id="author-save" data-i18n="checklisten.author.save">Speichern</button>
<a className="btn btn-secondary" href="/checklists?tab=mine" data-i18n="checklisten.author.cancel">Abbrechen</a>
</div>
</form>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-author.js"></script>
</body>
</html>
);
}

View File

@@ -39,12 +39,28 @@ export function renderChecklistsDetail(): string {
<div>
<h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
{/* Provenance line — visible only for authored
templates; populated by the client from the
catalog response's owner_display_name. */}
<p className="checklist-provenance" id="checklist-provenance" style="display:none" />
<dl className="checklist-meta" id="checklist-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
Neue Instanz
</button>
{/* Owner controls (Slice B) — toggled on by the
client once /api/checklists/{slug} returns
origin='authored' AND owner_email matches the
logged-in user. Kept hidden by default so
guests / non-owners never see them. */}
<a id="btn-edit-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.edit">Bearbeiten</a>
<button type="button" id="btn-share-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.share">Teilen</button>
<button type="button" id="btn-delete-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
{/* global_admin controls — revealed by the client
when /api/me reports global_role='global_admin'. */}
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
@@ -122,6 +138,65 @@ export function renderChecklistsDetail(): string {
</div>
</div>
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
opens it. Four recipient kinds in a single modal: pick the kind,
then the matching entity (user / office / partner_unit / project). */}
<div className="modal-overlay" id="share-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
<button className="modal-close" id="share-close" type="button">&times;</button>
</div>
<div className="form-field">
<label data-i18n="checklisten.share.kind">Empf&auml;ngertyp</label>
<div className="filter-pills" id="share-kind-pills">
<button type="button" className="filter-pill active" data-kind="user" data-i18n="checklisten.share.kind.user">Kollege</button>
<button type="button" className="filter-pill" data-kind="office" data-i18n="checklisten.share.kind.office">Office</button>
<button type="button" className="filter-pill" data-kind="partner_unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</button>
<button type="button" className="filter-pill" data-kind="project" data-i18n="checklisten.share.kind.project">Projekt</button>
</div>
</div>
<div className="form-field share-kind-section" data-kind="user">
<label htmlFor="share-user" data-i18n="checklisten.share.kind.user">Kollege</label>
<select id="share-user">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="office" style="display:none">
<label htmlFor="share-office" data-i18n="checklisten.share.kind.office">Office</label>
<select id="share-office">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="partner_unit" style="display:none">
<label htmlFor="share-partner-unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</label>
<select id="share-partner-unit">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="project" style="display:none">
<label htmlFor="share-project" data-i18n="checklisten.share.kind.project">Projekt</label>
<select id="share-project">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="share-cancel" data-i18n="checklisten.share.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="share-submit" data-i18n="checklisten.share.submit">Freigeben</button>
</div>
<p className="form-msg" id="share-msg" />
{/* Existing grants — populated on open from
/api/checklists/templates/{slug}/shares. */}
<h3 className="share-grants-heading" data-i18n="checklisten.share.grants.heading">Bestehende Freigaben</h3>
<ul className="share-grants-list" id="share-grants-list">
<li className="entity-events-empty" id="share-grants-empty" data-i18n="checklisten.share.grants.empty">Keine Freigaben.</li>
</ul>
</div>
</div>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">

View File

@@ -58,6 +58,10 @@ export function renderChecklistsInstance(): string {
</div>
<p className="tool-subtitle" id="instance-template-title">&nbsp;</p>
<dl className="checklist-meta" id="instance-meta" />
{/* Slice C: 'template updated since this instance
was created' banner. Populated by the client
when instance.template_version &lt; template.version. */}
<div id="instance-outdated-slot" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
@@ -118,6 +122,21 @@ export function renderChecklistsInstance(): string {
</div>
</div>
{/* Slice C: template-diff modal — opened from the
"Änderungen anzeigen" button on the outdated banner. */}
<div className="modal-overlay" id="instance-diff-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.instance.diff.title">Ge&auml;nderte Punkte</h2>
<button className="modal-close" id="instance-diff-close" type="button">&times;</button>
</div>
<div id="instance-diff-body" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="instance-diff-close-bottom" data-i18n="checklisten.instance.diff.close">Schlie&szlig;en</button>
</div>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-instance.js"></script>

View File

@@ -34,6 +34,8 @@ export function renderChecklists(): string {
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
<a className="entity-tab" data-tab="mine" href="/checklists?tab=mine" data-i18n="checklisten.tab.mine">Meine Vorlagen</a>
<a className="entity-tab" data-tab="gallery" href="/checklists?tab=gallery" data-i18n="checklisten.tab.gallery">Geteilte Vorlagen</a>
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
</nav>
@@ -49,6 +51,36 @@ export function renderChecklists(): string {
<div className="checklist-grid" id="checklist-grid" />
</section>
{/* Meine Vorlagen tab — caller's own authored templates */}
<section className="entity-tab-panel" id="tab-mine" style="display:none">
<div className="tool-actions" style="margin-bottom:1rem">
<a href="/checklists/new" className="btn btn-primary" data-i18n="checklisten.mine.new">Neue Vorlage</a>
</div>
<p className="entity-events-empty" id="checklists-mine-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-mine-empty" style="display:none" data-i18n="checklisten.mine.empty">
Sie haben noch keine eigene Vorlage angelegt.
</p>
<div className="checklist-grid" id="checklists-mine-grid" style="display:none" />
</section>
{/* Geteilte Vorlagen tab — discovery surface for templates
that aren't owned by the caller (firm-published,
globally-promoted, or explicitly shared). Slice C. */}
<section className="entity-tab-panel" id="tab-gallery" style="display:none">
<div className="checklist-filters" id="checklist-gallery-filters">
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
<button className="filter-pill" data-regime="OTHER" type="button" data-i18n="checklisten.filter.other">Sonstige</button>
</div>
<p className="entity-events-empty" id="checklists-gallery-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-gallery-empty" style="display:none" data-i18n="checklisten.gallery.empty">
Noch keine geteilten Vorlagen sichtbar.
</p>
<div className="checklist-grid" id="checklists-gallery-grid" style="display:none" />
</section>
{/* Instances tab — every visible instance across templates */}
<section className="entity-tab-panel" id="tab-instances" style="display:none">
<p className="entity-events-empty" id="checklists-instances-loading" data-i18n="checklisten.instances.all.loading">L&auml;dt&hellip;</p>

View File

@@ -468,11 +468,125 @@ function initInviteButton() {
});
}
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
// the auth.users row (via Supabase Admin API) and the paliad.users row in
// one POST. New user appears in dropdowns immediately. Welcome email with
// magic-link is sent by default; admin can opt out via the checkbox.
function openAddFullModal() {
const modal = document.getElementById("admin-add-full-modal")!;
const fb = document.getElementById("admin-af-feedback")!;
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
fb.style.display = "none";
emailField.value = "";
nameField.value = "";
jobTitleField.value = "";
profSel.value = "associate";
langSel.value = "de";
sendWelcome.checked = true;
officeSel.innerHTML = officeOptions("munich");
modal.style.display = "flex";
emailField.focus();
}
function closeAddFullModal() {
document.getElementById("admin-add-full-modal")!.style.display = "none";
}
function initAddFullModal() {
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeAddFullModal();
});
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
// Pre-fill the display name from the email local-part the first time the
// admin tabs out of the email field — mirrors the existing onboard flow.
emailField.addEventListener("blur", () => {
if (nameField.value || !emailField.value) return;
const local = emailField.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
});
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-af-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
const payload: Record<string, unknown> = {
email: emailField.value.trim().toLowerCase(),
display_name: nameField.value.trim(),
office: officeSel.value,
job_title: jobTitleField.value.trim() || "Associate",
profession: profSel.value,
lang: langSel.value,
send_welcome_mail: sendWelcome.checked,
};
submitBtn.disabled = true;
try {
const resp = await fetch("/api/admin/users/full", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
// Map two friendly cases inline; everything else surfaces the
// server message so the admin can act on it.
if (resp.status === 503) {
fb.textContent = t("admin.team.add_full.error.unavailable")
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
} else if (resp.status === 409) {
fb.textContent = body.error
|| (t("admin.team.add_full.error.email_exists")
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
} else {
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
}
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeAddFullModal();
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
render();
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initAddFullModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();

View File

@@ -1,193 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Appointment {
id: string;
project_id?: string;
title: string;
start_at: string;
end_at?: string;
appointment_type?: string;
project_reference?: string;
project_title?: string;
}
let allAppointments: Appointment[] = [];
let viewYear = 0;
let viewMonth = 0;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
async function loadAppointments() {
// Pull a wide window (current month plus a little buffer either side).
// We could narrow this, but the user typically navigates ±1-2 months
// and the dataset is small.
try {
const resp = await fetch("/api/appointments");
if (resp.ok) allAppointments = await resp.json();
} catch {
/* non-fatal */
}
}
function appointmentsForDate(iso: string): Appointment[] {
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
}
function typeClass(t?: string): string {
return t ? `termin-type-${t}` : "termin-type-default";
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = appointmentsForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("appointment-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allAppointments.some((tt) => {
const iso = tt.start_at.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("appointment-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = appointmentsForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((tt) => {
const akteRef = tt.project_id
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
return `<li class="frist-cal-popup-item">
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
${akteRef}
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadAppointments();
render();
});

View File

@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import {
bucketByDate,
filterByDay,
isToday,
isoDate,
shift,
startOfDay,
startOfWeek,
type CalendarItem,
} from "./mount-calendar";
// Regression tests for t-paliad-224: the calendar bucket / week / shift
// helpers underpin both /events Kalender and the Custom Views shape=
// calendar. DOM-rendering is covered by manual smoke (frontend tests in
// this repo run in plain Node, no jsdom — see verfahrensablauf-core.test
// ts comment), so the pure date-math goes here.
const item = (overrides: Partial<CalendarItem> = {}): CalendarItem => ({
kind: "deadline",
id: "00000000-0000-0000-0000-000000000000",
title: "Klageerwiderung",
event_date: "2026-05-08T00:00:00Z",
...overrides,
});
describe("isoDate / startOfDay / startOfWeek", () => {
test("isoDate pads month + day", () => {
expect(isoDate(new Date(2026, 0, 3))).toBe("2026-01-03");
expect(isoDate(new Date(2026, 11, 31))).toBe("2026-12-31");
});
test("startOfDay strips time", () => {
const d = new Date(2026, 4, 8, 13, 47, 22);
const out = startOfDay(d);
expect(out.getHours()).toBe(0);
expect(out.getMinutes()).toBe(0);
expect(out.getSeconds()).toBe(0);
expect(isoDate(out)).toBe("2026-05-08");
});
test("startOfWeek snaps to Monday (Mon=0)", () => {
// 2026-05-08 was a Friday.
const fri = new Date(2026, 4, 8);
expect(isoDate(startOfWeek(fri))).toBe("2026-05-04");
// Sunday wraps backward to the same Monday, not forward to the next.
const sun = new Date(2026, 4, 10);
expect(isoDate(startOfWeek(sun))).toBe("2026-05-04");
// Monday is its own startOfWeek.
const mon = new Date(2026, 4, 4);
expect(isoDate(startOfWeek(mon))).toBe("2026-05-04");
});
});
describe("shift", () => {
test("month shift lands on day=1 of the target month", () => {
const out = shift(new Date(2026, 4, 15), "month", 1);
expect(out.getFullYear()).toBe(2026);
expect(out.getMonth()).toBe(5);
expect(out.getDate()).toBe(1);
});
test("month shift wraps year boundary", () => {
const out = shift(new Date(2026, 11, 15), "month", 1);
expect(out.getFullYear()).toBe(2027);
expect(out.getMonth()).toBe(0);
expect(out.getDate()).toBe(1);
});
test("week shift moves seven days", () => {
const out = shift(new Date(2026, 4, 8), "week", 1);
expect(isoDate(out)).toBe("2026-05-15");
});
test("day shift moves one day", () => {
const out = shift(new Date(2026, 4, 8), "day", -1);
expect(isoDate(out)).toBe("2026-05-07");
});
});
describe("bucketByDate", () => {
test("groups items by ISO date and skips items outside the filter", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T15:30:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
// outside the May 2026 filter:
item({ id: "x", event_date: "2026-06-01T00:00:00Z" }),
// malformed:
item({ id: "bad", event_date: "not-a-date" }),
];
const out = bucketByDate(rows, (d) => d.getMonth() === 4 && d.getFullYear() === 2026);
expect(out.size).toBe(2);
expect(out.get("2026-05-08")?.map((r) => r.id)).toEqual(["a", "b"]);
expect(out.get("2026-05-09")?.map((r) => r.id)).toEqual(["c"]);
expect(out.has("2026-06-01")).toBe(false);
});
});
describe("filterByDay", () => {
test("returns only items whose calendar day equals the target", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T23:59:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["a", "b"]);
expect(filterByDay(rows, new Date(2026, 4, 9)).map((r) => r.id)).toEqual(["c"]);
expect(filterByDay(rows, new Date(2026, 4, 10))).toEqual([]);
});
test("ignores malformed dates", () => {
const rows = [
item({ id: "ok", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "bad", event_date: "not-a-date" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["ok"]);
});
});
describe("isToday", () => {
test("matches today's calendar day", () => {
expect(isToday(new Date())).toBe(true);
});
test("rejects yesterday + tomorrow", () => {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
expect(isToday(yesterday)).toBe(false);
expect(isToday(tomorrow)).toBe(false);
});
});

View File

@@ -0,0 +1,579 @@
import { t, tDyn, getLang, type I18nKey } from "../i18n";
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
// Lifted from the original shape-calendar.ts so both Custom Views
// (shape=calendar) and /events Kalender tab render through the same DOM.
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
//
// Surfaces wire in via mountCalendar(host, items, opts). The returned
// handle exposes update(items) for re-render after a filter change and
// destroy() for teardown when the host swaps to a different view.
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
/** ISO-8601 timestamp or date string. First 10 chars are read as the
* calendar bucket (yyyy-mm-dd). */
event_date: string;
project_id?: string;
project_title?: string;
project_reference?: string;
}
export type CalendarView = "month" | "week" | "day";
export interface CalendarOpts {
/** Initial view if URL has no override (or urlState is disabled). */
defaultView?: CalendarView;
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
* Surfaces that own their own URL contract pass urlState=false. */
urlState?: boolean;
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
* meaningful when urlState=true. Leave empty for the default
* ?cal_view / ?cal_date contract. */
urlPrefix?: string;
/** Override how a row's href is built. Default routes by kind. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Replace the item set and re-paint at the current view+anchor. */
update(items: CalendarItem[]): void;
/** Clear host + drop the keep-alive state. After destroy(), the handle
* is dead; create a fresh one with mountCalendar(). */
destroy(): void;
}
const MAX_PILLS_PER_MONTH_CELL = 3;
export function mountCalendar(
host: HTMLElement,
initialItems: CalendarItem[],
opts: CalendarOpts = {},
): CalendarHandle {
let items = initialItems;
let view: CalendarView;
let anchor: Date;
let destroyed = false;
const urlEnabled = opts.urlState ?? false;
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
view = urlEnabled
? readView(viewParam, opts.defaultView ?? "month")
: (opts.defaultView ?? "month");
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
paint();
return {
update(nextItems) {
if (destroyed) return;
items = nextItems;
paint();
},
destroy() {
destroyed = true;
host.innerHTML = "";
},
};
// --- paint -----------------------------------------------------------
function paint(): void {
if (destroyed) return;
host.innerHTML = "";
// Mobile fallback notice (<600px). Documented in design-calendar-
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
// notice just nudges users toward a friendlier view.
if (typeof window !== "undefined" && window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar());
if (view === "month") {
wrap.appendChild(renderMonth());
} else if (view === "week") {
wrap.appendChild(renderWeek());
} else {
wrap.appendChild(renderDay());
}
host.appendChild(wrap);
}
function setView(nextView: CalendarView, nextAnchor: Date): void {
view = nextView;
anchor = nextAnchor;
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
paint();
}
// --- Toolbar ---------------------------------------------------------
function renderToolbar(): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalendarView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
setView(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
nav.appendChild(next);
// "Heute" button — jump back to today in the current view. Adds a
// recognisable affordance for the /events Kalender users who relied
// on the old toolbar's "Heute" button.
const today = document.createElement("button");
today.type = "button";
today.className = "btn-secondary btn-small views-calendar-nav-btn";
today.textContent = t("cal.today");
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
nav.appendChild(today);
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => setView("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
// --- Month -----------------------------------------------------------
function renderMonth(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
grid.appendChild(cell);
}
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
const byDate = bucketByDate(items, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) ul.appendChild(renderPill(row));
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week ------------------------------------------------------------
function renderWeek(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
grid.appendChild(renderWeekColumn(day));
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
const dayRows = filterByDay(items, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day -------------------------------------------------------------
function renderDay(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(items, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering ---------------------------------------------------
function renderPill(row: CalendarItem): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = hrefFor(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = hrefFor(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function hrefFor(row: CalendarItem): string {
if (opts.hrefFor) return opts.hrefFor(row);
return defaultHrefFor(row);
}
}
// --- Pure helpers (shared, not closure-bound) ----------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
function defaultHrefFor(row: CalendarItem): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
export function bucketByDate(
rows: CalendarItem[], filter: (d: Date) => boolean,
): Map<string, CalendarItem[]> {
const out = new Map<string, CalendarItem[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
});
}
export function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
}
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function shift(d: Date, view: CalendarView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
export function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
export function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalendarView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
function firstAnchor(rows: CalendarItem[]): Date {
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return startOfDay(d);
}
return startOfDay(new Date());
}
function paramName(prefix: string | undefined, base: string): string {
if (!prefix) return base;
return `${prefix}_${base}`;
}
function readView(viewParam: string, fallback: CalendarView): CalendarView {
if (typeof window === "undefined") return fallback;
const params = new URLSearchParams(window.location.search);
const raw = params.get(viewParam);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return fallback;
}
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
if (typeof window === "undefined") return firstAnchor(rows);
const params = new URLSearchParams(window.location.search);
const raw = params.get(dateParam);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
return firstAnchor(rows);
}
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(viewParam, view);
url.searchParams.set(dateParam, isoDate(anchor));
history.replaceState(null, "", url.toString());
}

View File

@@ -0,0 +1,365 @@
// Authoring wizard for paliad.checklists. Serves both /checklists/new
// (create) and /checklists/templates/{slug}/edit (edit). The HTML bundle is the
// same; this client reads location.pathname to decide which mode to
// boot into.
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface Item {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface Group {
titleDE: string;
titleEN: string;
items: Item[];
}
interface Checklist {
id: string;
slug: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
body: { groups: Group[] };
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function detectMode(): { mode: "create" | "edit"; slug?: string } {
const path = window.location.pathname;
if (path === "/checklists/new") {
return { mode: "create" };
}
const m = path.match(/^\/checklists\/templates\/([^/]+)\/edit$/);
if (m) {
return { mode: "edit", slug: m[1] };
}
return { mode: "create" };
}
let groups: Group[] = [];
function renderGroups() {
const container = document.getElementById("groups-container")!;
if (groups.length === 0) {
// Seed with a single empty group + item so the user has something
// to fill out rather than a blank canvas.
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
container.innerHTML = groups.map((g, gi) => {
const itemsHTML = g.items.map((it, ii) => {
return `<div class="author-item" data-gi="${gi}" data-ii="${ii}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.label"))}</label>
<input class="form-input" data-field="label" value="${escAttr(it.labelDE || "")}" />
</div>
<div class="form-grid form-grid-2">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.note"))}</label>
<input class="form-input" data-field="note" value="${escAttr(it.noteDE || "")}" />
</div>
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.rule"))}</label>
<input class="form-input" data-field="rule" value="${escAttr(it.rule || "")}" />
</div>
</div>
<button type="button" class="btn btn-small btn-danger" data-action="remove-item">${esc(t("checklisten.author.item.remove"))}</button>
</div>`;
}).join("");
return `<div class="author-group" data-gi="${gi}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.group.title"))}</label>
<input class="form-input" data-field="group-title" value="${escAttr(g.titleDE || "")}" />
</div>
<div class="author-items">${itemsHTML}</div>
<div class="author-group-actions">
<button type="button" class="btn btn-small" data-action="add-item">${esc(t("checklisten.author.item.add"))}</button>
<button type="button" class="btn btn-small btn-danger" data-action="remove-group">${esc(t("checklisten.author.group.remove"))}</button>
</div>
</div>`;
}).join("");
// Wire input changes back into the data array.
container.querySelectorAll<HTMLInputElement>(".author-group > .form-row input[data-field=group-title]").forEach((input) => {
const groupDiv = input.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
input.addEventListener("input", () => {
groups[gi].titleDE = input.value;
groups[gi].titleEN = input.value; // single-language for Slice A
});
});
container.querySelectorAll<HTMLDivElement>(".author-item").forEach((itemDiv) => {
const gi = parseInt(itemDiv.dataset.gi!, 10);
const ii = parseInt(itemDiv.dataset.ii!, 10);
itemDiv.querySelectorAll<HTMLInputElement>("input[data-field]").forEach((input) => {
input.addEventListener("input", () => {
const field = input.dataset.field!;
if (field === "label") {
groups[gi].items[ii].labelDE = input.value;
groups[gi].items[ii].labelEN = input.value;
} else if (field === "note") {
groups[gi].items[ii].noteDE = input.value || undefined;
groups[gi].items[ii].noteEN = input.value || undefined;
} else if (field === "rule") {
groups[gi].items[ii].rule = input.value || undefined;
}
});
});
itemDiv.querySelector<HTMLButtonElement>("button[data-action=remove-item]")!.addEventListener("click", () => {
groups[gi].items.splice(ii, 1);
if (groups[gi].items.length === 0) {
groups[gi].items.push({ labelDE: "", labelEN: "" });
}
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=add-item]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups[gi].items.push({ labelDE: "", labelEN: "" });
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=remove-group]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups.splice(gi, 1);
renderGroups();
});
});
}
function showError(msg: string) {
const err = document.getElementById("author-error")!;
err.textContent = msg;
err.style.display = "";
err.scrollIntoView({ behavior: "smooth", block: "center" });
}
function clearError() {
const err = document.getElementById("author-error")!;
err.textContent = "";
err.style.display = "none";
}
function collectInput() {
const title = (document.getElementById("title") as HTMLInputElement).value.trim();
const description = (document.getElementById("description") as HTMLTextAreaElement).value.trim();
const regime = (document.getElementById("regime") as HTMLSelectElement).value;
const court = (document.getElementById("court") as HTMLInputElement).value.trim();
const reference = (document.getElementById("reference") as HTMLInputElement).value.trim();
const deadline = (document.getElementById("deadline") as HTMLInputElement).value.trim();
const lang = (document.getElementById("lang") as HTMLSelectElement).value;
const visibilityInput = document.querySelector<HTMLInputElement>("input[name=visibility]:checked");
const visibility = visibilityInput?.value || "private";
return { title, description, regime, court, reference, deadline, lang, visibility };
}
function validateGroups(): boolean {
if (groups.length === 0) return false;
let totalItems = 0;
for (const g of groups) {
if (!g.titleDE.trim()) return false;
for (const it of g.items) {
if (it.labelDE.trim()) totalItems += 1;
}
}
return totalItems > 0;
}
function trimmedGroups(): Group[] {
return groups
.filter((g) => g.titleDE.trim() && g.items.some((it) => it.labelDE.trim()))
.map((g) => ({
titleDE: g.titleDE.trim(),
titleEN: g.titleEN.trim(),
items: g.items
.filter((it) => it.labelDE.trim())
.map((it) => ({
labelDE: it.labelDE.trim(),
labelEN: it.labelEN.trim(),
noteDE: it.noteDE?.trim() || undefined,
noteEN: it.noteEN?.trim() || undefined,
rule: it.rule?.trim() || undefined,
})),
}));
}
async function loadEditTemplate(slug: string) {
// Use /api/checklists/{slug} (catalog Find with visibility check) +
// the mine list to ensure we have the editable fields. Templates the
// caller doesn't own/admin will trip the PATCH gate later.
const resp = await fetch(`/api/checklists/templates/mine`);
if (!resp.ok) {
showError(t("checklisten.author.error.notfound"));
return;
}
const rows: Checklist[] = (await resp.json()) ?? [];
const tpl = rows.find((r) => r.slug === slug);
if (!tpl) {
showError(t("checklisten.author.error.notfound"));
return;
}
(document.getElementById("author-heading")!).textContent = t("checklisten.author.heading.edit");
document.title = t("checklisten.author.title.edit");
(document.getElementById("title") as HTMLInputElement).value = tpl.title;
(document.getElementById("description") as HTMLTextAreaElement).value = tpl.description;
(document.getElementById("regime") as HTMLSelectElement).value = tpl.regime;
(document.getElementById("court") as HTMLInputElement).value = tpl.court;
(document.getElementById("reference") as HTMLInputElement).value = tpl.reference;
(document.getElementById("deadline") as HTMLInputElement).value = tpl.deadline;
(document.getElementById("lang") as HTMLSelectElement).value = tpl.lang || "de";
const visIn = document.querySelector<HTMLInputElement>(`input[name=visibility][value=${tpl.visibility}]`);
if (visIn) visIn.checked = true;
groups = (tpl.body?.groups || []).map((g) => ({
titleDE: g.titleDE || "",
titleEN: g.titleEN || g.titleDE || "",
items: g.items.map((it) => ({
labelDE: it.labelDE || "",
labelEN: it.labelEN || it.labelDE || "",
noteDE: it.noteDE,
noteEN: it.noteEN,
rule: it.rule,
})),
}));
if (groups.length === 0) {
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
renderGroups();
}
async function submitCreate() {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const body = JSON.stringify({ ...input, body: { groups: trimmedGroups() } });
const resp = await fetch("/api/checklists/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
const created: Checklist = await resp.json();
window.location.href = `/checklists/${encodeURIComponent(created.slug)}`;
}
async function submitEdit(slug: string) {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const patch = {
title: input.title,
description: input.description,
regime: input.regime,
court: input.court,
reference: input.reference,
deadline: input.deadline,
body: { groups: trimmedGroups() },
};
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
// Visibility lives on its own endpoint so the audit row reflects the
// distinct transition. Only call if it actually changed.
if (resp.ok && input.visibility) {
await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}/visibility`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visibility: input.visibility }),
});
}
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
window.location.href = `/checklists/${encodeURIComponent(slug)}`;
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
renderGroups();
document.getElementById("add-group")!.addEventListener("click", () => {
groups.push({ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] });
renderGroups();
});
const { mode, slug } = detectMode();
if (mode === "edit" && slug) {
void loadEditTemplate(slug);
}
document.getElementById("author-form")!.addEventListener("submit", (e) => {
e.preventDefault();
if (mode === "edit" && slug) {
void submitEdit(slug);
} else {
void submitCreate();
}
});
});

View File

@@ -30,6 +30,37 @@ interface Checklist {
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
// Slice B fields — present on authored entries via the merged
// catalog response. 'static' templates don't carry these.
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface Me {
id: string;
email: string;
display_name: string;
global_role?: string;
}
interface UserSummary {
id: string;
email: string;
display_name: string;
}
interface PartnerUnit {
id: string;
name: string;
}
interface Share {
id: string;
checklist_id: string;
recipient_kind: "user" | "office" | "partner_unit" | "project";
recipient_label: string;
}
interface ChecklistInstance {
@@ -371,13 +402,320 @@ function rerenderAll() {
renderInstances();
}
// --- Slice B: owner actions + admin promote + share modal ----------------
let me: Me | null = null;
let isOwner = false;
let isAdmin = false;
let shareUsers: UserSummary[] = [];
let sharePartnerUnits: PartnerUnit[] = [];
let shareProjects: AkteSummary[] = [];
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
async function loadMe(): Promise<Me | null> {
try {
const resp = await fetch("/api/me");
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
function templateOriginInfo() {
return template as unknown as {
origin?: string;
visibility?: string;
owner_email?: string;
owner_display_name?: string;
} | null;
}
function applyOwnerControls() {
const info = templateOriginInfo();
const isAuthored = info?.origin === "authored";
const provenance = document.getElementById("checklist-provenance")!;
if (isAuthored && info?.owner_display_name) {
provenance.style.display = "";
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
} else {
provenance.style.display = "none";
}
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
isAdmin = !!(me && me.global_role === "global_admin");
const ownerOnly = (id: string, show: boolean) => {
const el = document.getElementById(id);
if (el) (el as HTMLElement).style.display = show ? "" : "none";
};
if (template) {
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
"href",
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
);
}
ownerOnly("btn-edit-template", isOwner);
ownerOnly("btn-share-template", isOwner);
ownerOnly("btn-delete-template", isOwner);
// Admin promote/demote — only when an authored template is visible to
// an admin, and only the appropriate one for the current visibility.
if (isAuthored && isAdmin) {
const isGlobal = info?.visibility === "global";
ownerOnly("btn-promote-template", !isGlobal);
ownerOnly("btn-demote-template", isGlobal);
} else {
ownerOnly("btn-promote-template", false);
ownerOnly("btn-demote-template", false);
}
}
function initOwnerActions() {
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
if (!template) return;
const isEN = getLang() === "en";
const title = isEN ? template.titleEN : template.titleDE;
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.detail.delete.error"));
return;
}
window.location.href = "/checklists?tab=mine";
});
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: "firm" }),
});
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
}
async function loadSharePickerData() {
// Fire all three lookups in parallel — the share modal needs all of
// them but doesn't depend on their order.
try {
const [usersResp, unitsResp, projectsResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/partner-units"),
fetch("/api/projects"),
]);
shareUsers = usersResp.ok ? await usersResp.json() : [];
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
} catch {
/* leave whatever loaded */
}
populateSharePickerOptions();
}
function populateSharePickerOptions() {
const userSel = document.getElementById("share-user") as HTMLSelectElement;
if (userSel) {
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareUsers
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.forEach((u) => {
if (me && u.id === me.id) return; // can't share with self
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = `${u.display_name} (${u.email})`;
userSel.appendChild(opt);
});
}
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
if (officeSel) {
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
officeKeys.forEach((k) => {
const opt = document.createElement("option");
opt.value = k;
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
officeSel.appendChild(opt);
});
}
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
if (puSel) {
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
sharePartnerUnits
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((u) => {
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = u.name;
puSel.appendChild(opt);
});
}
const prSel = document.getElementById("share-project") as HTMLSelectElement;
if (prSel) {
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareProjects
.slice()
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = `${p.reference || ""}${p.title}`;
prSel.appendChild(opt);
});
}
}
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
activeShareKind = kind;
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
p.classList.toggle("active", p.dataset.kind === kind);
});
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
s.style.display = s.dataset.kind === kind ? "" : "none";
});
}
function initShareModal() {
const modal = document.getElementById("share-modal")!;
const msg = document.getElementById("share-msg")!;
const close = () => { modal.style.display = "none"; };
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
if (!template) return;
msg.textContent = "";
msg.className = "form-msg";
switchShareKind("user");
modal.style.display = "flex";
await loadSharePickerData();
await renderGrants();
});
document.getElementById("share-close")?.addEventListener("click", close);
document.getElementById("share-cancel")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
if (!btn) return;
switchShareKind(btn.dataset.kind as typeof activeShareKind);
});
document.getElementById("share-submit")?.addEventListener("click", async () => {
if (!template) return;
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
switch (activeShareKind) {
case "user": {
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_user_id"] = v;
break;
}
case "office": {
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_office"] = v;
break;
}
case "partner_unit": {
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_partner_unit_id"] = v;
break;
}
case "project": {
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_project_id"] = v;
break;
}
}
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!resp.ok) {
let errMsg = t("checklisten.share.error.generic");
try {
const j = await resp.json();
if (j?.error) errMsg = j.error;
} catch { /* keep generic */ }
msg.textContent = errMsg;
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.share.success");
msg.className = "form-msg form-msg-success";
await renderGrants();
});
}
async function renderGrants() {
if (!template) return;
const list = document.getElementById("share-grants-list")!;
const empty = document.getElementById("share-grants-empty")!;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
const rows: Share[] = resp.ok ? await resp.json() : [];
if (rows.length === 0) {
list.innerHTML = "";
list.appendChild(empty);
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = rows.map((s) => {
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
return `<li class="share-grant-row" data-id="${esc(s.id)}">
<span class="share-grant-kind">${kindLabel}</span>
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
</li>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
window.alert(t("checklisten.share.grants.revoke.error"));
return;
}
await renderGrants();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initNewInstance();
initFeedback();
initOwnerActions();
initShareModal();
onLangChange(rerenderAll);
void loadTemplate();
void (async () => {
me = await loadMe();
await loadTemplate();
applyOwnerControls();
})();
void loadInstances();
void loadAkten();
});

View File

@@ -40,6 +40,16 @@ interface Instance {
created_by: string;
created_at: string;
updated_at: string;
// Slice C — snapshot of the template body + its version at create time.
template_snapshot?: { groups: ChecklistGroup[] } | null;
template_version?: number | null;
}
// Slice C — augmented Checklist with origin + version, returned by
// /api/checklists/{slug}.
interface ChecklistWithMeta extends Checklist {
origin?: "static" | "authored";
version?: number;
}
let template: Checklist | null = null;
@@ -155,6 +165,119 @@ function renderHeader() {
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
}
document.getElementById("instance-meta")!.innerHTML = parts.join("");
renderOutdatedBadge();
}
// Slice C — show an "outdated" badge when the live template has a
// version > the instance's snapshot version. Both values must be
// non-null for the comparison to be meaningful (pre-Slice-C instances
// have NULL template_version; static templates always have version=1
// and never bump).
function renderOutdatedBadge() {
const slot = document.getElementById("instance-outdated-slot");
if (!slot || !instance || !template) return;
const tplMeta = template as ChecklistWithMeta;
const instVersion = instance.template_version;
const tplVersion = tplMeta.version;
if (
instVersion == null ||
tplVersion == null ||
tplMeta.origin !== "authored" ||
tplVersion <= instVersion
) {
slot.innerHTML = "";
return;
}
const badge = esc(t("checklisten.instance.outdated.badge"));
const note = esc(
t("checklisten.instance.outdated.note")
.replace("{from}", String(instVersion))
.replace("{to}", String(tplVersion)),
);
const action = esc(t("checklisten.instance.outdated.diff"));
slot.innerHTML = `<div class="instance-outdated-banner">
<span class="instance-outdated-badge">${badge}</span>
<span class="instance-outdated-note">${note}</span>
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
</div>`;
document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal);
}
// Shallow diff between two checklist bodies. Compares item label/note/
// rule pairs grouped by section title. Items with the same group title
// + same label are matched; differences in note/rule are flagged
// 'changed'. Items present only in snapshot are 'removed'; items only
// in current are 'added'.
function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]):
{ added: string[]; removed: string[]; changed: string[] } {
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
const oldGroups = snapshot?.groups ?? [];
const oldMap: Record<string, ChecklistItem> = {};
for (const g of oldGroups) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
oldMap[key] = it;
}
}
const newMap: Record<string, ChecklistItem> = {};
for (const g of current) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
newMap[key] = it;
if (!(key in oldMap)) {
added.push(it.labelDE || it.labelEN);
} else {
const o = oldMap[key];
if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") ||
(o.rule || "") !== (it.rule || "")) {
changed.push(it.labelDE || it.labelEN);
}
}
}
}
for (const key in oldMap) {
if (!(key in newMap)) {
const labelParts = key.split("::");
removed.push(labelParts[1] || key);
}
}
return { added, removed, changed };
}
function openDiffModal() {
if (!template || !instance) return;
const modal = document.getElementById("instance-diff-modal")!;
const body = document.getElementById("instance-diff-body")!;
const diff = diffBodies(instance.template_snapshot, template.groups);
const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
if (empty) {
body.innerHTML = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
} else {
const section = (label: string, klass: string, items: string[]) => {
if (items.length === 0) return "";
return `<section class="instance-diff-section ${klass}">
<h3>${esc(label)}</h3>
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
</section>`;
};
body.innerHTML = [
section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added),
section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed),
section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed),
].join("");
}
modal.style.display = "flex";
}
function initDiffModal() {
const modal = document.getElementById("instance-diff-modal");
if (!modal) return;
const close = () => { modal.style.display = "none"; };
document.getElementById("instance-diff-close")?.addEventListener("click", close);
document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
}
function renderGroups() {
@@ -389,6 +512,7 @@ document.addEventListener("DOMContentLoaded", () => {
initPrint();
initRename();
initFeedback();
initDiffModal();
onLangChange(renderAll);
void bootstrap();
});

View File

@@ -11,6 +11,26 @@ interface ChecklistSummary {
courtDE: string;
courtEN: string;
itemCount: number;
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface MyChecklist {
id: string;
slug: string;
owner_id: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
created_at: string;
updated_at: string;
}
interface ChecklistInstance {
@@ -26,15 +46,20 @@ interface ChecklistInstance {
project_title?: string | null;
}
type TabId = "templates" | "instances";
type TabId = "templates" | "mine" | "gallery" | "instances";
const VALID_TABS: TabId[] = ["templates", "instances"];
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
let galleryRegime = "all";
let allInstances: ChecklistInstance[] = [];
let templatesBySlug: Record<string, ChecklistSummary> = {};
let instancesLoaded = false;
let myTemplates: MyChecklist[] = [];
let myTemplatesLoaded = false;
let galleryLoaded = false;
let me: { id: string; email: string } | null = null;
let activeTab: TabId = "templates";
function esc(s: string): string {
@@ -208,7 +233,10 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
if (opts.pushHistory ?? true) {
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
let newURL = "/checklists";
if (tab === "instances") newURL = "/checklists?tab=instances";
if (tab === "mine") newURL = "/checklists?tab=mine";
if (tab === "gallery") newURL = "/checklists?tab=gallery";
if (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
@@ -216,6 +244,155 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
if (tab === "instances") {
void loadInstances();
}
if (tab === "mine") {
void loadMyTemplates();
}
if (tab === "gallery") {
void loadGallery();
}
}
async function loadGallery(force = false) {
if (galleryLoaded && !force) return;
galleryLoaded = true;
// /api/checklists already returns the merged catalog; the gallery
// filter just narrows to non-static + non-owned + non-private.
if (allChecklists.length === 0) {
await loadTemplates();
}
renderGallery();
}
function renderGallery() {
const loading = document.getElementById("checklists-gallery-loading")!;
const empty = document.getElementById("checklists-gallery-empty")!;
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
loading.style.display = "none";
const visible = allChecklists.filter((c) => {
if (c.origin !== "authored") return false;
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
return true;
});
if (visible.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
const isEN = getLang() === "en";
grid.innerHTML = visible.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const itemsLabel = isEN ? "items" : "Punkte";
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
const authorLine = c.owner_display_name
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
: "";
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
${authorLine}
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
</a>`;
}).join("");
}
function initGalleryFilters() {
const container = document.getElementById("checklist-gallery-filters");
if (!container) return;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
galleryRegime = btn.dataset.regime ?? "all";
renderGallery();
});
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch { /* leave me=null */ }
}
async function loadMyTemplates(force = false) {
if (myTemplatesLoaded && !force) return;
myTemplatesLoaded = true;
const resp = await fetch("/api/checklists/templates/mine");
if (!resp.ok) {
myTemplates = [];
} else {
myTemplates = (await resp.json()) ?? [];
}
renderMyTemplates();
}
function renderMyTemplates() {
const loading = document.getElementById("checklists-mine-loading")!;
const empty = document.getElementById("checklists-mine-empty")!;
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
loading.style.display = "none";
if (myTemplates.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
grid.innerHTML = myTemplates.map((tpl) => {
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
const visLabel = esc(t(visKey as never) || tpl.visibility);
const titleSafe = esc(tpl.title);
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
</div>
<h2 class="checklist-card-title">
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
</h2>
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
<div class="checklist-card-actions">
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
</div>
</article>`;
}).join("");
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
const slug = btn.dataset.slug!;
const title = btn.dataset.title || slug;
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.mine.delete.error"));
return;
}
await loadMyTemplates(true);
});
});
}
function initTabs() {
@@ -234,11 +411,15 @@ document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
initGalleryFilters();
initTabs();
onLangChange(() => {
renderTemplates();
if (instancesLoaded) renderInstances();
if (myTemplatesLoaded) renderMyTemplates();
if (galleryLoaded) renderGallery();
});
void loadMe();
void loadTemplates();
showTab(parseTab(), { pushHistory: false });
});

View File

@@ -1,181 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Deadline {
id: string;
project_id: string;
title: string;
due_date: string;
status: string;
project_reference: string;
project_title: string;
}
let allDeadlines: Deadline[] = [];
let viewYear = 0;
let viewMonth = 0; // 0-11
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
async function loadDeadlines() {
try {
const resp = await fetch("/api/deadlines?status=all");
if (resp.ok) allDeadlines = await resp.json();
} catch {
/* non-fatal */
}
}
function deadlinesForDate(iso: string): Deadline[] {
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = deadlinesForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("deadline-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allDeadlines.some((f) => {
const iso = f.due_date.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("deadline-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = deadlinesForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((f) => {
const cls = urgencyClass(f.due_date, f.status);
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadDeadlines();
render();
});

View File

@@ -8,6 +8,7 @@ import {
type FilterHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
@@ -157,8 +158,10 @@ let me: Me | null = null;
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
let loadedOK = false;
let calYear = 0;
let calMonth = 0;
// Calendar handle is created lazily when /events first switches into the
// Kalender view (t-paliad-224). The handle owns its own month/week/day
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
let calendar: CalendarHandle | null = null;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
@@ -429,12 +432,13 @@ function hideTableAndCalendar() {
const calWrap = document.getElementById("events-calendar-wrap");
if (tableWrap) tableWrap.style.display = "none";
if (calWrap) calWrap.hidden = true;
teardownCalendar();
}
function render() {
if (!loadedOK) return;
if (currentView === "calendar") {
renderCalendar();
renderCalendarView();
} else {
renderTable();
}
@@ -557,135 +561,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
</tr>`;
}
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
// plotting an event onto the calendar. Deadlines bucket on due_date;
// appointments on start_at's local-date component.
function itemDateISO(item: EventListItem): string {
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
// event_date); appointments bucket on start_at (fallback to event_date).
function toCalendarItem(item: EventListItem): CalendarItem {
let bucketDate: string;
if (item.type === "deadline") {
const src = item.due_date ?? item.event_date;
return src.slice(0, 10);
bucketDate = item.due_date ?? item.event_date;
} else if (item.start_at) {
bucketDate = item.start_at;
} else {
bucketDate = item.event_date;
}
if (!item.start_at) return item.event_date.slice(0, 10);
const d = new Date(item.start_at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return {
kind: item.type,
id: item.id,
title: item.title,
event_date: bucketDate,
project_id: item.project_id,
project_title: item.project_title,
project_reference: item.project_reference,
};
}
function isoDate(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function fmtMonthYear(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function calDotClass(item: EventListItem): string {
// Per-item dot colour. Deadlines reuse the existing urgency palette;
// appointments get their own colour so they're visually distinct from
// deadlines on a mixed (Beides) calendar.
if (item.type === "appointment") return "events-cal-dot-appointment";
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
}
function renderCalendar() {
const wrap = document.getElementById("events-calendar-wrap")!;
const grid = document.getElementById("events-cal-grid")!;
const empty = document.getElementById("events-cal-empty") as HTMLElement;
const monthLabel = document.getElementById("events-cal-month-label")!;
function renderCalendarView() {
const host = document.getElementById("events-calendar-wrap");
if (!host) return;
const tableEmpty = document.getElementById("events-empty")!;
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
// Calendar always renders the visible month from allItems, regardless of
// pristine vs filtered state — empty calendar is allowed (the per-month
// empty hint communicates "no items in this month" without confusing it
// with the table-mode "no items at all" empty state).
tableEmpty.style.display = "none";
tableEmptyFiltered.style.display = "none";
wrap.hidden = false;
(host as HTMLElement).hidden = false;
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
const firstDay = new Date(calYear, calMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
const byDate = new Map<string, EventListItem[]>();
for (const item of allItems) {
const iso = itemDateISO(item);
const list = byDate.get(iso);
if (list) list.push(item);
else byDate.set(iso, [item]);
const items = allItems.map(toCalendarItem);
if (calendar) {
calendar.update(items);
return;
}
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(calYear, calMonth, day);
const items = byDate.get(iso) ?? [];
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
// urlState=true: the Kalender tab persists its month/week/day + anchor
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
// calendar state (per t-paliad-224 §11 Q3 head decision).
calendar = mountCalendar(host as HTMLElement, items, {
urlState: true,
defaultView: "month",
});
const monthStart = isoDate(calYear, calMonth, 1);
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
const hasInMonth = allItems.some((it) => {
const iso = itemDateISO(it);
return iso >= monthStart && iso <= monthEnd;
});
empty.hidden = hasInMonth;
}
function openCalPopup(iso: string, items: EventListItem[]) {
if (items.length === 0) return;
const popup = document.getElementById("events-cal-popup") as HTMLElement;
const dateEl = document.getElementById("events-cal-popup-date")!;
const list = document.getElementById("events-cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((it) => {
const cls = calDotClass(it);
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
const projectLabel = it.project_reference ?? "";
const projectCell = projectHref
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
: "";
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
${projectCell}
</li>`;
})
.join("");
popup.style.display = "flex";
function teardownCalendar() {
if (!calendar) return;
calendar.destroy();
calendar = null;
}
function applyView() {
@@ -706,12 +632,18 @@ function applyView() {
// Cards view = the original layout (5-card summary + table).
// List view = no summary cards, table only — gives more vertical space
// and matches users' mental model of a flat list.
// Calendar view = month grid; cards + table both hidden.
// Calendar view = mountCalendar() canon (month/week/day); cards + table
// both hidden. The handle is torn down when the user leaves Kalender
// so its URL state isn't reapplied to other shapes.
summary.style.display = currentView === "cards" ? "" : "none";
tableWrap.style.display = currentView === "calendar" ? "none" : "";
calWrap.hidden = currentView !== "calendar";
if (currentView === "calendar" && loadedOK) renderCalendar();
if (currentView === "calendar") {
if (loadedOK) renderCalendarView();
} else {
teardownCalendar();
}
}
function wireRowHandlers(tbody: HTMLElement) {
@@ -1013,12 +945,10 @@ function initFilters() {
}
function initView() {
// Calendar always opens on the current month — month navigation is
// local to the view (cheap pagination, doesn't refetch).
const now = new Date();
calYear = now.getFullYear();
calMonth = now.getMonth();
// Kalender state (view + anchor) lives inside mountCalendar; no
// events-page-level wiring needed. The view chips below switch
// between Karten / Liste / Kalender; applyView() handles the
// mount + teardown.
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.dataset.eventView as EventView;
@@ -1028,31 +958,6 @@ function initView() {
syncURLParams();
});
});
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
calMonth -= 1;
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
renderCalendar();
});
document.getElementById("events-cal-next")?.addEventListener("click", () => {
calMonth += 1;
if (calMonth > 11) { calMonth = 0; calYear += 1; }
renderCalendar();
});
document.getElementById("events-cal-today")?.addEventListener("click", () => {
const t = new Date();
calYear = t.getFullYear();
calMonth = t.getMonth();
renderCalendar();
});
const popup = document.getElementById("events-cal-popup") as HTMLElement;
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
popup.style.display = "none";
});
popup?.addEventListener("click", (e) => {
if (e.target === popup) popup.style.display = "none";
});
}
function initSummaryCards() {

View File

@@ -555,7 +555,101 @@ const translations: Record<Lang, Record<string, string>> = {
"checklisten.heading": "Checklisten",
"checklisten.subtitle": "Interaktive Checklisten f\u00fcr typische Verfahrensschritte vor UPC, BPatG und EPA. Abhaken, ausdrucken, kein Punkt vergessen.",
"checklisten.tab.templates": "Vorlagen",
"checklisten.tab.mine": "Meine Vorlagen",
"checklisten.tab.instances": "Vorhandene Instanzen",
"checklisten.mine.empty": "Sie haben noch keine eigene Vorlage angelegt.",
"checklisten.tab.gallery": "Geteilte Vorlagen",
"checklisten.gallery.empty": "Noch keine geteilten Vorlagen sichtbar.",
"checklisten.filter.other": "Sonstige",
"checklisten.instance.outdated.badge": "Vorlage aktualisiert",
"checklisten.instance.outdated.note": "Die zugrundeliegende Vorlage wurde seit dem Anlegen dieser Instanz aktualisiert (v{from} → v{to}).",
"checklisten.instance.outdated.diff": "Änderungen anzeigen",
"checklisten.instance.diff.title": "Geänderte Punkte",
"checklisten.instance.diff.close": "Schließen",
"checklisten.instance.diff.added": "Neu",
"checklisten.instance.diff.removed": "Entfernt",
"checklisten.instance.diff.changed": "Geändert",
"checklisten.instance.diff.empty": "Keine inhaltlichen Unterschiede in den Punkten.",
"checklisten.instance.diff.error": "Vergleich fehlgeschlagen.",
"checklisten.mine.new": "Neue Vorlage",
"checklisten.mine.loading": "Lädt…",
"checklisten.mine.visibility.private": "Privat",
"checklisten.mine.visibility.firm": "Firmenweit",
"checklisten.mine.visibility.shared": "Geteilt",
"checklisten.mine.visibility.global": "Im Katalog",
"checklisten.mine.edit": "Bearbeiten",
"checklisten.mine.delete": "Löschen",
"checklisten.mine.delete.confirm": "Vorlage „{title}“ wirklich löschen? Bestehende Instanzen bleiben erhalten.",
"checklisten.mine.delete.error": "Löschen fehlgeschlagen.",
"checklisten.mine.origin.authored": "Eigene Vorlage",
"checklisten.author.title": "Vorlage erstellen — Paliad",
"checklisten.author.title.edit": "Vorlage bearbeiten — Paliad",
"checklisten.author.heading.new": "Neue Checklisten-Vorlage",
"checklisten.author.heading.edit": "Vorlage bearbeiten",
"checklisten.author.subtitle": "Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten. Sie können sie privat halten oder firmenweit verfügbar machen.",
"checklisten.author.field.title": "Titel",
"checklisten.author.field.title.hint": "z.B. „UPC SoC — interne Checkliste“.",
"checklisten.author.field.description": "Kurzbeschreibung",
"checklisten.author.field.regime": "Regime",
"checklisten.author.field.court": "Gericht / Behörde",
"checklisten.author.field.reference": "Rechtsgrundlage",
"checklisten.author.field.deadline": "Deadline (optional)",
"checklisten.author.field.lang": "Sprache",
"checklisten.author.field.visibility": "Sichtbarkeit",
"checklisten.author.visibility.private.hint": "Nur für Sie sichtbar.",
"checklisten.author.visibility.firm.hint": "Für alle angemeldeten Kolleginnen und Kollegen sichtbar.",
"checklisten.author.groups.heading": "Sektionen und Punkte",
"checklisten.author.groups.add": "+ Sektion hinzufügen",
"checklisten.author.group.title": "Sektionsname",
"checklisten.author.group.remove": "Sektion löschen",
"checklisten.author.item.add": "+ Punkt hinzufügen",
"checklisten.author.item.label": "Punkt",
"checklisten.author.item.note": "Notiz (optional)",
"checklisten.author.item.rule": "Vorschrift (optional)",
"checklisten.author.item.remove": "Punkt löschen",
"checklisten.author.save": "Speichern",
"checklisten.author.cancel": "Abbrechen",
"checklisten.author.saving": "Speichert…",
"checklisten.author.error.title": "Bitte geben Sie einen Titel ein.",
"checklisten.author.error.no_groups": "Bitte mindestens eine Sektion mit einem Punkt anlegen.",
"checklisten.author.error.generic": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
"checklisten.author.error.notfound": "Diese Vorlage existiert nicht oder Sie haben keine Berechtigung sie zu bearbeiten.",
"checklisten.detail.edit": "Bearbeiten",
"checklisten.detail.delete": "Löschen",
"checklisten.detail.share": "Teilen",
"checklisten.detail.promote": "Als Firmen-Vorlage hinterlegen",
"checklisten.detail.demote": "Aus Katalog entfernen",
"checklisten.detail.promote.confirm": "Diese Vorlage in den Firmen-Katalog übernehmen? Alle Kolleg:innen sehen sie dann unter Vorlagen.",
"checklisten.detail.demote.confirm": "Vorlage aus dem Firmen-Katalog entfernen? Sie bleibt firmenweit sichtbar.",
"checklisten.detail.promote.error": "Übernahme fehlgeschlagen.",
"checklisten.detail.delete.confirm": "Vorlage „{title}\" wirklich löschen? Bestehende Instanzen bleiben erhalten.",
"checklisten.detail.delete.error": "Löschen fehlgeschlagen.",
"checklisten.detail.authored.by": "Erstellt von {author}",
"checklisten.detail.visibility": "Sichtbarkeit: {state}",
"checklisten.detail.visibility.set.firm": "Für Firma freigeben",
"checklisten.detail.visibility.set.private": "Privat schalten",
"checklisten.detail.visibility.error": "Sichtbarkeit konnte nicht geändert werden.",
"checklisten.share.title": "Vorlage teilen",
"checklisten.share.kind": "Empfängertyp",
"checklisten.share.kind.user": "Kollege",
"checklisten.share.kind.office": "Office",
"checklisten.share.kind.partner_unit": "Dezernat",
"checklisten.share.kind.project": "Projekt",
"checklisten.share.pick": "— auswählen —",
"checklisten.share.submit": "Freigeben",
"checklisten.share.cancel": "Abbrechen",
"checklisten.share.error.pick": "Bitte einen Empfänger auswählen.",
"checklisten.share.error.generic": "Freigeben fehlgeschlagen.",
"checklisten.share.success": "Freigegeben.",
"checklisten.share.grants.heading": "Bestehende Freigaben",
"checklisten.share.grants.empty": "Keine Freigaben.",
"checklisten.share.grants.revoke": "Entfernen",
"checklisten.share.grants.revoke.confirm": "Freigabe entfernen?",
"checklisten.share.grants.revoke.error": "Entfernen fehlgeschlagen.",
"checklisten.share.grants.recipient.user": "Kollege",
"checklisten.share.grants.recipient.office": "Office",
"checklisten.share.grants.recipient.partner_unit": "Dezernat",
"checklisten.share.grants.recipient.project": "Projekt",
"checklisten.instances.all.loading": "L\u00e4dt\u2026",
"checklisten.instances.all.empty": "Noch keine Checklisten-Instanzen erfasst. Legen Sie eine \u00fcber den Vorlagen-Tab an.",
"checklisten.instances.all.col.template": "Vorlage",
@@ -694,7 +788,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.list.heading": "Fristen",
"deadlines.list.subtitle": "Persistente Fristen f\u00fcr Ihre Akten. \u00dcberf\u00e4llig, heute, diese Woche, n\u00e4chste Woche \u2014 auf einen Blick.",
"deadlines.list.new": "Neue Frist",
"deadlines.list.calendar": "Kalenderansicht",
"deadlines.summary.overdue": "\u00dcberf\u00e4llig",
"deadlines.summary.today": "Heute",
"deadlines.summary.thisweek": "Diese Woche",
@@ -817,12 +910,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.source.caldav": "CalDAV",
"deadlines.source.imported": "Import",
"deadlines.kalender.title": "Fristenkalender \u2014 Paliad",
"deadlines.kalender.heading": "Fristenkalender",
"deadlines.kalender.subtitle": "Monats\u00fcbersicht aller Fristen Ihrer Akten.",
"deadlines.kalender.list": "Listenansicht",
"deadlines.kalender.today": "Heute",
"deadlines.kalender.empty": "Keine Fristen im ausgew\u00e4hlten Zeitraum.",
"cal.day.mon": "Mo",
"cal.day.tue": "Di",
"cal.day.wed": "Mi",
@@ -1600,7 +1687,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.list.title": "Termine \u2014 Paliad",
"appointments.list.heading": "Termine",
"appointments.list.subtitle": "Verhandlungen, Besprechungen, Beratungen \u2014 pers\u00f6nlich oder aktenbezogen.",
"appointments.list.calendar": "Kalenderansicht",
"appointments.list.new": "Neuer Termin",
"appointments.summary.today": "Heute",
"appointments.summary.thisweek": "Diese Woche",
@@ -1656,11 +1742,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.detail.saved": "Gespeichert.",
"appointments.detail.delete": "Termin l\u00f6schen",
"appointments.detail.delete.confirm": "Diesen Termin wirklich l\u00f6schen?",
"appointments.kalender.title": "Terminkalender \u2014 Paliad",
"appointments.kalender.heading": "Terminkalender",
"appointments.kalender.subtitle": "Monats\u00fcbersicht aller Termine.",
"appointments.kalender.list": "Listenansicht",
"appointments.kalender.empty": "Keine Termine im ausgew\u00e4hlten Zeitraum.",
// t-paliad-110 \u2014 unified Events page (rendered on both /deadlines and
// /appointments). The user-facing "Fristen" / "Termine" branding stays;
@@ -1684,7 +1765,6 @@ const translations: Record<Lang, Record<string, string>> = {
"events.view.cards": "Karten",
"events.view.list": "Liste",
"events.view.calendar": "Kalender",
"events.calendar.empty": "Keine Eintr\u00e4ge im ausgew\u00e4hlten Zeitraum.",
"caldav.title": "CalDAV-Synchronisation \u2014 Paliad",
"caldav.heading": "CalDAV-Synchronisation",
"caldav.subtitle": "Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow\u2026). Das Passwort wird verschl\u00fcsselt gespeichert und nie zur\u00fcckgegeben.",
@@ -2077,8 +2157,24 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.heading": "Team-Verwaltung",
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
"admin.team.search.placeholder": "Nach Name oder E-Mail suchen…",
"admin.team.add.full": "Konto direkt anlegen",
"admin.team.add.direct": "Bestehendes Konto onboarden",
"admin.team.add.invite": "Neue:n Kolleg:in einladen",
"admin.team.add_full.title": "Konto direkt anlegen",
"admin.team.add_full.body": "Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.",
"admin.team.add_full.email": "E-Mail",
"admin.team.add_full.name": "Anzeigename",
"admin.team.add_full.office": "Standort",
"admin.team.add_full.profession": "Profession",
"admin.team.add_full.job_title": "Berufsbezeichnung",
"admin.team.add_full.lang": "Sprache",
"admin.team.add_full.send_welcome": "Willkommens-E-Mail mit Login-Link senden",
"admin.team.add_full.cancel": "Abbrechen",
"admin.team.add_full.submit": "Anlegen",
"admin.team.add_full.feedback.added": "Konto angelegt.",
"admin.team.add_full.error.unavailable": "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).",
"admin.team.add_full.error.email_exists": "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.",
"admin.team.add_full.error.generic": "Konto konnte nicht angelegt werden.",
"admin.team.loading": "Lade…",
"admin.team.empty": "Keine Treffer.",
"admin.team.error.forbidden": "Zugriff nur für Admins.",
@@ -3282,7 +3378,101 @@ const translations: Record<Lang, Record<string, string>> = {
"checklisten.heading": "Checklists",
"checklisten.subtitle": "Interactive checklists for typical procedural steps before the UPC, German Patent Court, and EPO. Tick off, print, miss nothing.",
"checklisten.tab.templates": "Templates",
"checklisten.tab.mine": "My templates",
"checklisten.tab.instances": "Existing instances",
"checklisten.mine.empty": "You haven't authored a template yet.",
"checklisten.tab.gallery": "Shared templates",
"checklisten.gallery.empty": "No shared templates visible yet.",
"checklisten.filter.other": "Other",
"checklisten.instance.outdated.badge": "Template updated",
"checklisten.instance.outdated.note": "The underlying template has been updated since this instance was created (v{from} → v{to}).",
"checklisten.instance.outdated.diff": "Show changes",
"checklisten.instance.diff.title": "Changed items",
"checklisten.instance.diff.close": "Close",
"checklisten.instance.diff.added": "Added",
"checklisten.instance.diff.removed": "Removed",
"checklisten.instance.diff.changed": "Changed",
"checklisten.instance.diff.empty": "No content differences in items.",
"checklisten.instance.diff.error": "Diff failed.",
"checklisten.mine.new": "New template",
"checklisten.mine.loading": "Loading…",
"checklisten.mine.visibility.private": "Private",
"checklisten.mine.visibility.firm": "Firm-wide",
"checklisten.mine.visibility.shared": "Shared",
"checklisten.mine.visibility.global": "In catalog",
"checklisten.mine.edit": "Edit",
"checklisten.mine.delete": "Delete",
"checklisten.mine.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
"checklisten.mine.delete.error": "Delete failed.",
"checklisten.mine.origin.authored": "Your template",
"checklisten.author.title": "Author template — Paliad",
"checklisten.author.title.edit": "Edit template — Paliad",
"checklisten.author.heading.new": "New checklist template",
"checklisten.author.heading.edit": "Edit template",
"checklisten.author.subtitle": "Author your own checklist with sections and items. Keep it private or open it firm-wide.",
"checklisten.author.field.title": "Title",
"checklisten.author.field.title.hint": "e.g. \"UPC SoC — internal checklist\".",
"checklisten.author.field.description": "Short description",
"checklisten.author.field.regime": "Regime",
"checklisten.author.field.court": "Court / authority",
"checklisten.author.field.reference": "Legal source",
"checklisten.author.field.deadline": "Deadline (optional)",
"checklisten.author.field.lang": "Language",
"checklisten.author.field.visibility": "Visibility",
"checklisten.author.visibility.private.hint": "Visible only to you.",
"checklisten.author.visibility.firm.hint": "Visible to every authenticated colleague.",
"checklisten.author.groups.heading": "Sections and items",
"checklisten.author.groups.add": "+ Add section",
"checklisten.author.group.title": "Section title",
"checklisten.author.group.remove": "Remove section",
"checklisten.author.item.add": "+ Add item",
"checklisten.author.item.label": "Item",
"checklisten.author.item.note": "Note (optional)",
"checklisten.author.item.rule": "Rule (optional)",
"checklisten.author.item.remove": "Remove item",
"checklisten.author.save": "Save",
"checklisten.author.cancel": "Cancel",
"checklisten.author.saving": "Saving…",
"checklisten.author.error.title": "Please enter a title.",
"checklisten.author.error.no_groups": "Please add at least one section with one item.",
"checklisten.author.error.generic": "Save failed. Please try again.",
"checklisten.author.error.notfound": "Template not found or you don't have permission to edit it.",
"checklisten.detail.edit": "Edit",
"checklisten.detail.delete": "Delete",
"checklisten.detail.share": "Share",
"checklisten.detail.promote": "Add to firm catalog",
"checklisten.detail.demote": "Remove from catalog",
"checklisten.detail.promote.confirm": "Add this template to the firm catalog? Every colleague will see it under Templates.",
"checklisten.detail.demote.confirm": "Remove this template from the firm catalog? It stays firm-visible.",
"checklisten.detail.promote.error": "Promotion failed.",
"checklisten.detail.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
"checklisten.detail.delete.error": "Delete failed.",
"checklisten.detail.authored.by": "Authored by {author}",
"checklisten.detail.visibility": "Visibility: {state}",
"checklisten.detail.visibility.set.firm": "Share with firm",
"checklisten.detail.visibility.set.private": "Make private",
"checklisten.detail.visibility.error": "Couldn't change visibility.",
"checklisten.share.title": "Share template",
"checklisten.share.kind": "Recipient type",
"checklisten.share.kind.user": "Colleague",
"checklisten.share.kind.office": "Office",
"checklisten.share.kind.partner_unit": "Practice unit",
"checklisten.share.kind.project": "Project",
"checklisten.share.pick": "— pick —",
"checklisten.share.submit": "Share",
"checklisten.share.cancel": "Cancel",
"checklisten.share.error.pick": "Please pick a recipient.",
"checklisten.share.error.generic": "Share failed.",
"checklisten.share.success": "Shared.",
"checklisten.share.grants.heading": "Existing grants",
"checklisten.share.grants.empty": "No grants.",
"checklisten.share.grants.revoke": "Remove",
"checklisten.share.grants.revoke.confirm": "Remove this grant?",
"checklisten.share.grants.revoke.error": "Revoke failed.",
"checklisten.share.grants.recipient.user": "Colleague",
"checklisten.share.grants.recipient.office": "Office",
"checklisten.share.grants.recipient.partner_unit": "Practice unit",
"checklisten.share.grants.recipient.project": "Project",
"checklisten.instances.all.loading": "Loading…",
"checklisten.instances.all.empty": "No checklist instances yet. Create one from the Templates tab.",
"checklisten.instances.all.col.template": "Template",
@@ -3421,7 +3611,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.list.heading": "Deadlines",
"deadlines.list.subtitle": "Persistent deadlines for your matters. Overdue, today, this week, next week \u2014 at a glance.",
"deadlines.list.new": "New deadline",
"deadlines.list.calendar": "Calendar view",
"deadlines.summary.overdue": "Overdue",
"deadlines.summary.today": "Today",
"deadlines.summary.thisweek": "This week",
@@ -3544,12 +3733,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.source.caldav": "CalDAV",
"deadlines.source.imported": "Import",
"deadlines.kalender.title": "Deadline calendar \u2014 Paliad",
"deadlines.kalender.heading": "Deadline calendar",
"deadlines.kalender.subtitle": "Monthly view of all deadlines on your matters.",
"deadlines.kalender.list": "List view",
"deadlines.kalender.today": "Today",
"deadlines.kalender.empty": "No deadlines in the selected period.",
"cal.day.mon": "Mon",
"cal.day.tue": "Tue",
"cal.day.wed": "Wed",
@@ -3579,6 +3762,7 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.day.prev": "Previous day",
"cal.day.next": "Next day",
"cal.day.back_to_month": "Back to month",
"cal.today": "Today",
"cal.day.open_day": "Open day view",
"cal.day.no_entries": "Nothing scheduled this day.",
@@ -4313,7 +4497,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.list.title": "Appointments \u2014 Paliad",
"appointments.list.heading": "Appointments",
"appointments.list.subtitle": "Hearings, meetings, consultations \u2014 personal or matter-linked.",
"appointments.list.calendar": "Calendar view",
"appointments.list.new": "New appointment",
"appointments.summary.today": "Today",
"appointments.summary.thisweek": "This week",
@@ -4369,11 +4552,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.detail.saved": "Saved.",
"appointments.detail.delete": "Delete appointment",
"appointments.detail.delete.confirm": "Really delete this appointment?",
"appointments.kalender.title": "Appointment calendar \u2014 Paliad",
"appointments.kalender.heading": "Appointment calendar",
"appointments.kalender.subtitle": "Monthly overview of all appointments.",
"appointments.kalender.list": "List view",
"appointments.kalender.empty": "No appointments in the selected period.",
// t-paliad-110 \u2014 unified Events page (rendered on /deadlines + /appointments).
"events.toggle.deadline": "Deadlines",
@@ -4394,7 +4572,6 @@ const translations: Record<Lang, Record<string, string>> = {
"events.view.cards": "Cards",
"events.view.list": "List",
"events.view.calendar": "Calendar",
"events.calendar.empty": "No entries in the selected period.",
"caldav.title": "CalDAV sync \u2014 Paliad",
"caldav.heading": "CalDAV sync",
"caldav.subtitle": "Sync your Paliad appointments with your external calendar (Nextcloud, iCloud, Outlook, mailcow\u2026). The password is stored encrypted and never returned.",
@@ -4785,8 +4962,24 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.heading": "Team Management",
"admin.team.subtitle": "View, edit and add Paliad accounts.",
"admin.team.search.placeholder": "Search by name or email…",
"admin.team.add.full": "Add account directly",
"admin.team.add.direct": "Onboard existing account",
"admin.team.add.invite": "Invite Colleague",
"admin.team.add_full.title": "Add account directly",
"admin.team.add_full.body": "Creates both the login account and the Paliad profile. The new colleague receives an email with a link to set a password.",
"admin.team.add_full.email": "Email",
"admin.team.add_full.name": "Display name",
"admin.team.add_full.office": "Office",
"admin.team.add_full.profession": "Profession",
"admin.team.add_full.job_title": "Job title",
"admin.team.add_full.lang": "Language",
"admin.team.add_full.send_welcome": "Send welcome email with login link",
"admin.team.add_full.cancel": "Cancel",
"admin.team.add_full.submit": "Create",
"admin.team.add_full.feedback.added": "Account created.",
"admin.team.add_full.error.unavailable": "Add-User path is not configured (SUPABASE_SERVICE_ROLE_KEY missing on the server).",
"admin.team.add_full.error.email_exists": "An account already exists for this email — please use 'Onboard existing account' instead.",
"admin.team.add_full.error.generic": "Could not create the account.",
"admin.team.loading": "Loading…",
"admin.team.empty": "No matches.",
"admin.team.error.forbidden": "Admins only.",

View File

@@ -93,12 +93,13 @@ export function routeNameFor(pathname: string): string {
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
if (pathname === "/deadlines/new") return "deadlines.new";
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
if (pathname === "/deadlines") return "deadlines.list";
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
if (pathname === "/appointments/new") return "appointments.new";
if (pathname === "/appointments/calendar") return "appointments.calendar";
if (pathname === "/appointments") return "appointments.list";
// /deadlines/calendar + /appointments/calendar are 301 redirects to
// /events?type=…&view=calendar since t-paliad-224 — the client never
// sees those pathnames any more.
if (pathname === "/agenda") return "agenda";
if (pathname === "/inbox") return "inbox";
if (pathname === "/dashboard" || pathname === "/") return "dashboard";

View File

@@ -1,525 +1,28 @@
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month / week / day views. The view switcher is rendered
// inline above the grid; the active view persists in the URL via
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
// shareable deep-link. Each view buckets the same flat ViewRow[] by
// ISO-date — only the rendering differs.
type CalView = "month" | "week" | "day";
const VIEW_PARAM = "cal_view";
const DATE_PARAM = "cal_date";
const MAX_PILLS_PER_MONTH_CELL = 3;
// shape-calendar — Custom Views calendar shape. Since t-paliad-224 this
// is a thin adapter on top of the canonical mountCalendar() in
// frontend/src/client/calendar/mount-calendar.ts. /events Kalender tab
// uses the same module so both surfaces render identical DOM.
// See docs/design-calendar-view-align-2026-05-20.md.
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
// screens). Documented in design §9 trade-off 8.
if (window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const initialView = readView(cfg.default_view);
const anchor = readAnchor(rows);
paint(host, rows, anchor, initialView);
}
// paint redraws the calendar in the supplied view + anchor. Called from
// the view switcher and from the day/week navigation buttons. Each paint
// clears the host so we don't leak prior DOM.
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
// Keep the mobile-notice (first child) if present; everything else is
// re-rendered each time.
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
host.innerHTML = "";
if (notice) host.appendChild(notice);
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
writeURL(nextView, nextAnchor);
paint(host, rows, nextAnchor, nextView);
}));
if (view === "month") {
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
writeURL("day", clickedDate);
paint(host, rows, clickedDate, "day");
}));
} else if (view === "week") {
wrap.appendChild(renderWeek(anchor, rows));
} else {
wrap.appendChild(renderDay(anchor, rows));
}
host.appendChild(wrap);
}
// --- Toolbar -------------------------------------------------------------
function renderToolbar(
view: CalView,
anchor: Date,
onNav: (view: CalView, anchor: Date) => void,
): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
// View switcher: month / week / day chips.
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
onNav(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
// Prev / current-label / next. Step size depends on the view.
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
nav.appendChild(next);
// Day/week view: provide a "Zurück zum Monat" link so users can climb
// back without hunting for the switcher chip.
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => onNav("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
// --- Month view ----------------------------------------------------------
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Single grid with one column-template that the weekday row and the day
// cells share. The header row is added with `grid-column: span 7` so
// it spans the full width above the day grid (laid out below).
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
grid.appendChild(cell);
}
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
// Pad start with prev-month spillover.
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
const byDate = bucketByDate(rows, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
grid.appendChild(cell);
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(
dayDate: Date,
dayNum: number,
dayRows: ViewRow[],
onDayDrill: (d: Date) => void,
): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
// Day-number is a click-target that switches to the day view. We render
// it as a button to keep keyboard semantics; the surrounding cell stays
// a div so it doesn't compete with the inner row anchors.
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) {
ul.appendChild(renderPill(row));
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week view -----------------------------------------------------------
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
const col = renderWeekColumn(day, rows);
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
// No 3-row cap on week / day views — show everything for that day.
const dayRows = filterByDay(rows, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day view ------------------------------------------------------------
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(rows, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering -------------------------------------------------------
function renderPill(row: ViewRow): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = rowHref(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
// Pills are anchors — month-cell day-button click ignores them via
// stopPropagation on the button; cell-level handlers would intercept
// them otherwise.
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = rowHref(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function rowHref(row: ViewRow): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event":
// project_events surface on the project's Verlauf — best we can do
// is link to the project. If no project, leave as a non-link target.
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
// --- Bucketing / date helpers --------------------------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
const out = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
const items: CalendarItem[] = rows.map(toCalendarItem);
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7; // Mon=0
out.setDate(out.getDate() - offset);
return out;
}
function shift(d: Date, view: CalView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
// --- URL state -----------------------------------------------------------
function readView(defaultView: CalView | undefined): CalView {
const params = new URLSearchParams(window.location.search);
const raw = params.get(VIEW_PARAM);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return defaultView ?? "month";
}
function readAnchor(rows: ViewRow[]): Date {
const params = new URLSearchParams(window.location.search);
const raw = params.get(DATE_PARAM);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
// No URL anchor — pick the first row's date, or today.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function writeURL(view: CalView, anchor: Date): void {
const url = new URL(window.location.href);
url.searchParams.set(VIEW_PARAM, view);
url.searchParams.set(DATE_PARAM, isoDate(anchor));
history.replaceState(null, "", url.toString());
function toCalendarItem(row: ViewRow): CalendarItem {
return {
kind: row.kind,
id: row.id,
title: row.title,
event_date: row.event_date,
project_id: row.project_id,
project_title: row.project_title,
project_reference: row.project_reference,
};
}

View File

@@ -1,84 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderDeadlinesCalendar(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="deadlines.kalender.title">Fristenkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=deadline" />
<BottomNav currentPath="/events?type=deadline" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="deadlines.kalender.heading">Fristenkalender</h1>
<p className="tool-subtitle" data-i18n="deadlines.kalender.subtitle">
Monats&uuml;bersicht aller Fristen Ihrer Akten.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=deadline" className="btn-secondary" data-i18n="deadlines.kalender.list">Listenansicht</a>
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">Neue Frist</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="frist-calendar" id="deadline-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="deadline-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="deadline-cal-empty" style="display:none" data-i18n="deadlines.kalender.empty">
Keine Fristen im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/deadlines-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -236,37 +236,10 @@ export function renderEvents(): string {
</table>
</div>
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
<div className="frist-calendar-controls">
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="events-cal-month-label" className="frist-cal-month-label" />
<button type="button" id="events-cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="events-cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="frist-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="events-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="events-cal-empty" hidden data-i18n="events.calendar.empty">
Keine Eintr&auml;ge im ausgew&auml;hlten Zeitraum.
</p>
</div>
<div className="modal-overlay" id="events-cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="events-cal-popup-date" />
<button className="modal-close" id="events-cal-popup-close" type="button" aria-label="Close">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
</div>
</div>
{/* Calendar host — mountCalendar() (t-paliad-224) builds the
month/week/day grid + toolbar into this container when
the Kalender view chip is active. Empty until then. */}
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden />
<div className="entity-empty" id="events-empty" style="display:none">
<h2 data-i18n="events.empty.title">Keine Eintr&auml;ge vorhanden</h2>

View File

@@ -440,7 +440,23 @@ export type I18nKey =
| "admin.section.planned"
| "admin.subtitle"
| "admin.team.add.direct"
| "admin.team.add.full"
| "admin.team.add.invite"
| "admin.team.add_full.body"
| "admin.team.add_full.cancel"
| "admin.team.add_full.email"
| "admin.team.add_full.error.email_exists"
| "admin.team.add_full.error.generic"
| "admin.team.add_full.error.unavailable"
| "admin.team.add_full.feedback.added"
| "admin.team.add_full.job_title"
| "admin.team.add_full.lang"
| "admin.team.add_full.name"
| "admin.team.add_full.office"
| "admin.team.add_full.profession"
| "admin.team.add_full.send_welcome"
| "admin.team.add_full.submit"
| "admin.team.add_full.title"
| "admin.team.col.actions"
| "admin.team.col.additional"
| "admin.team.col.created"
@@ -555,12 +571,6 @@ export type I18nKey =
| "appointments.filter.type"
| "appointments.filter.type.all"
| "appointments.form.approval_hint"
| "appointments.kalender.empty"
| "appointments.kalender.heading"
| "appointments.kalender.list"
| "appointments.kalender.subtitle"
| "appointments.kalender.title"
| "appointments.list.calendar"
| "appointments.list.heading"
| "appointments.list.new"
| "appointments.list.subtitle"
@@ -705,6 +715,7 @@ export type I18nKey =
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.today"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
@@ -789,7 +800,54 @@ export type I18nKey =
| "changelog.tag.feature"
| "changelog.tag.fix"
| "changelog.title"
| "checklisten.author.cancel"
| "checklisten.author.error.generic"
| "checklisten.author.error.no_groups"
| "checklisten.author.error.notfound"
| "checklisten.author.error.title"
| "checklisten.author.field.court"
| "checklisten.author.field.deadline"
| "checklisten.author.field.description"
| "checklisten.author.field.lang"
| "checklisten.author.field.reference"
| "checklisten.author.field.regime"
| "checklisten.author.field.title"
| "checklisten.author.field.title.hint"
| "checklisten.author.field.visibility"
| "checklisten.author.group.remove"
| "checklisten.author.group.title"
| "checklisten.author.groups.add"
| "checklisten.author.groups.heading"
| "checklisten.author.heading.edit"
| "checklisten.author.heading.new"
| "checklisten.author.item.add"
| "checklisten.author.item.label"
| "checklisten.author.item.note"
| "checklisten.author.item.remove"
| "checklisten.author.item.rule"
| "checklisten.author.save"
| "checklisten.author.saving"
| "checklisten.author.subtitle"
| "checklisten.author.title"
| "checklisten.author.title.edit"
| "checklisten.author.visibility.firm.hint"
| "checklisten.author.visibility.private.hint"
| "checklisten.back"
| "checklisten.detail.authored.by"
| "checklisten.detail.delete"
| "checklisten.detail.delete.confirm"
| "checklisten.detail.delete.error"
| "checklisten.detail.demote"
| "checklisten.detail.demote.confirm"
| "checklisten.detail.edit"
| "checklisten.detail.promote"
| "checklisten.detail.promote.confirm"
| "checklisten.detail.promote.error"
| "checklisten.detail.share"
| "checklisten.detail.visibility"
| "checklisten.detail.visibility.error"
| "checklisten.detail.visibility.set.firm"
| "checklisten.detail.visibility.set.private"
| "checklisten.disclaimer"
| "checklisten.empty"
| "checklisten.feedback.btn"
@@ -807,11 +865,23 @@ export type I18nKey =
| "checklisten.feedback.type"
| "checklisten.filter.all"
| "checklisten.filter.de"
| "checklisten.filter.other"
| "checklisten.gallery.empty"
| "checklisten.heading"
| "checklisten.instance.akte.open"
| "checklisten.instance.back"
| "checklisten.instance.diff.added"
| "checklisten.instance.diff.changed"
| "checklisten.instance.diff.close"
| "checklisten.instance.diff.empty"
| "checklisten.instance.diff.error"
| "checklisten.instance.diff.removed"
| "checklisten.instance.diff.title"
| "checklisten.instance.loading"
| "checklisten.instance.notfound"
| "checklisten.instance.outdated.badge"
| "checklisten.instance.outdated.diff"
| "checklisten.instance.outdated.note"
| "checklisten.instance.rename"
| "checklisten.instance.rename.error"
| "checklisten.instance.rename.save"
@@ -834,6 +904,18 @@ export type I18nKey =
| "checklisten.instances.heading"
| "checklisten.instances.loading"
| "checklisten.instances.sub"
| "checklisten.mine.delete"
| "checklisten.mine.delete.confirm"
| "checklisten.mine.delete.error"
| "checklisten.mine.edit"
| "checklisten.mine.empty"
| "checklisten.mine.loading"
| "checklisten.mine.new"
| "checklisten.mine.origin.authored"
| "checklisten.mine.visibility.firm"
| "checklisten.mine.visibility.global"
| "checklisten.mine.visibility.private"
| "checklisten.mine.visibility.shared"
| "checklisten.newInstance"
| "checklisten.newInstance.akte"
| "checklisten.newInstance.akte.hint"
@@ -850,8 +932,31 @@ export type I18nKey =
| "checklisten.reset"
| "checklisten.reset.confirm"
| "checklisten.reset.error"
| "checklisten.share.cancel"
| "checklisten.share.error.generic"
| "checklisten.share.error.pick"
| "checklisten.share.grants.empty"
| "checklisten.share.grants.heading"
| "checklisten.share.grants.recipient.office"
| "checklisten.share.grants.recipient.partner_unit"
| "checklisten.share.grants.recipient.project"
| "checklisten.share.grants.recipient.user"
| "checklisten.share.grants.revoke"
| "checklisten.share.grants.revoke.confirm"
| "checklisten.share.grants.revoke.error"
| "checklisten.share.kind"
| "checklisten.share.kind.office"
| "checklisten.share.kind.partner_unit"
| "checklisten.share.kind.project"
| "checklisten.share.kind.user"
| "checklisten.share.pick"
| "checklisten.share.submit"
| "checklisten.share.success"
| "checklisten.share.title"
| "checklisten.subtitle"
| "checklisten.tab.gallery"
| "checklisten.tab.instances"
| "checklisten.tab.mine"
| "checklisten.tab.templates"
| "checklisten.title"
| "common.cancel"
@@ -1120,13 +1225,6 @@ export type I18nKey =
| "deadlines.inbox.label"
| "deadlines.inbox.posteingang"
| "deadlines.inbox.posteingang.title"
| "deadlines.kalender.empty"
| "deadlines.kalender.heading"
| "deadlines.kalender.list"
| "deadlines.kalender.subtitle"
| "deadlines.kalender.title"
| "deadlines.kalender.today"
| "deadlines.list.calendar"
| "deadlines.list.heading"
| "deadlines.list.new"
| "deadlines.list.subtitle"
@@ -1454,7 +1552,6 @@ export type I18nKey =
| "event_types.picker.no_match"
| "event_types.picker.remove"
| "event_types.picker.search"
| "events.calendar.empty"
| "events.col.appointment_type"
| "events.col.date"
| "events.col.location"

View File

@@ -7470,158 +7470,10 @@ dialog.modal::backdrop {
max-width: 22rem;
}
/* Calendar view */
.frist-calendar-controls {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 1rem;
}
.frist-cal-month-label {
font-size: 1.15rem;
margin: 0;
min-width: 11rem;
text-align: center;
}
.frist-calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--color-border);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.frist-cal-weekday {
background: var(--color-surface-2);
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.4rem 0.6rem;
text-align: center;
}
.frist-cal-grid {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--color-border);
}
.frist-cal-cell {
background: var(--color-surface);
min-height: 88px;
padding: 0.4rem 0.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
cursor: default;
}
.frist-cal-cell-empty {
background: var(--color-bg-subtle);
}
.frist-cal-cell-has {
cursor: pointer;
}
.frist-cal-cell-has:hover {
background: var(--color-bg-lime-tint);
}
.frist-cal-day {
font-size: 0.8rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.frist-cal-today .frist-cal-day {
background: var(--color-accent);
color: var(--color-accent-dark);
border-radius: 999px;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.frist-cal-dots {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
}
.frist-cal-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--frist-grey);
}
.frist-cal-dot.frist-urgency-overdue { background: var(--frist-red); }
.frist-cal-dot.frist-urgency-soon { background: var(--frist-amber); }
.frist-cal-dot.frist-urgency-later { background: var(--frist-green); }
.frist-cal-dot.frist-urgency-done { background: var(--frist-grey); }
.frist-cal-more {
font-size: 0.7rem;
color: var(--color-text-muted);
margin-left: 0.2rem;
}
.frist-cal-popup-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.frist-cal-popup-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.frist-cal-popup-item:last-child {
border-bottom: none;
}
.frist-cal-popup-title {
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
.frist-cal-popup-title:hover {
text-decoration: underline;
}
.frist-cal-popup-akte {
color: var(--color-text-muted);
font-size: 0.8rem;
text-decoration: none;
}
.frist-cal-popup-akte:hover {
color: var(--color-text);
}
/* Calendar view styles live in .views-calendar-* (search for that
prefix). The /events Kalender tab and Custom Views shape=calendar
both mount the same component (frontend/src/client/calendar/
mount-calendar.ts, t-paliad-224). */
/* Fristenrechner save-to-Akte modal */
@@ -8027,9 +7879,6 @@ dialog.modal::backdrop {
.frist-summary-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.frist-cal-cell {
min-height: 64px;
}
}
/* ========================================================================
@@ -8688,27 +8537,6 @@ dialog.modal::backdrop {
.termin-card-week .frist-summary-dot { background: #2563eb; }
.termin-card-later .frist-summary-dot { background: #475569; }
.termin-cal-legend {
display: flex;
gap: 1.2rem;
flex-wrap: wrap;
margin: 0.5rem 0 1rem;
color: #475569;
font-size: 0.85rem;
}
.termin-cal-legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
/* Calendar popup: extra time column for termine (vs. the deadline popup). */
.frist-cal-popup-time {
color: #475569;
font-variant-numeric: tabular-nums;
margin-right: 0.5rem;
}
/* CalDAV settings page */
.caldav-status-card {
background: var(--color-surface-muted);
@@ -11527,18 +11355,13 @@ dialog.quick-add-sheet::backdrop {
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
}
/* Calendar view (t-paliad-115). Reuses the existing .frist-calendar
styles — only the appointment dot colour is new. The frist-cal-dot
urgency variants already cover the deadline palette; we just need a
distinct hue for appointments so a mixed-type cell reads at a glance. */
/* Calendar host — mountCalendar() (t-paliad-224) builds the toolbar +
grid into this wrapper when the user picks the Kalender chip. All
internal styling lives in .views-calendar-* (search for that prefix). */
.events-calendar-wrap {
margin: 0.25rem 0 1rem;
}
.frist-cal-dot.events-cal-dot-appointment {
background: var(--bucket-next-week, #1d4ed8);
}
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
.event-type-add-modal {
width: 28rem;

View File

@@ -0,0 +1,13 @@
-- Reverse of mig 114 — t-paliad-225 / m/paliad#61 Slice A.
ALTER TABLE paliad.checklist_instances
DROP COLUMN IF EXISTS template_snapshot;
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
DROP FUNCTION IF EXISTS paliad.can_see_checklist(uuid, uuid);
DROP TABLE IF EXISTS paliad.checklists;

View File

@@ -0,0 +1,178 @@
-- mig 114 — t-paliad-225 / m/paliad#61 Slice A — user-authored checklists.
--
-- Design: docs/design-user-checklists-2026-05-20.md
--
-- Introduces paliad.checklists (the authored-template catalog), the
-- paliad.can_see_checklist(uuid, uuid) visibility predicate, and a
-- nullable template_snapshot column on paliad.checklist_instances so
-- per-Akte instances stay decoupled from subsequent template edits.
--
-- Slice A ships with private + firm visibility only; the 'shared' and
-- 'global' values are valid in the CHECK enum so Slice B can add the
-- explicit-share path and admin-promotion without a second migration
-- to the enum.
--
-- Sections:
-- 1. CREATE TABLE paliad.checklists.
-- 2. paliad.can_see_checklist(uuid, uuid) predicate.
-- 3. RLS policies on paliad.checklists.
-- 4. ALTER TABLE paliad.checklist_instances ADD COLUMN template_snapshot.
--
-- Idempotent throughout (CREATE … IF NOT EXISTS / CREATE OR REPLACE
-- FUNCTION / DROP POLICY IF EXISTS + CREATE POLICY).
-- ============================================================================
-- 1. paliad.checklists — authored-template catalog.
--
-- The static Go catalog (internal/checklists/templates.go) stays the
-- firm's curated source for legally-reviewed templates. This table holds
-- user-authored templates that augment that catalog at read time via
-- ChecklistCatalogService.
--
-- Slugs are author-facing and unique within this table. The application
-- layer rejects slugs that collide with the static catalog (see
-- ChecklistTemplateService.Create — applies a 'u-' prefix and falls back
-- through a collision-retry loop).
--
-- body jsonb carries { "groups": [{ "title", "items": [{ "label", "note",
-- "rule" }] }] } — the same shape as the static checklists.Template
-- minus the metadata (which lives in dedicated columns).
-- ============================================================================
CREATE TABLE IF NOT EXISTS paliad.checklists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
regime text NOT NULL DEFAULT 'OTHER',
court text NOT NULL DEFAULT '',
reference text NOT NULL DEFAULT '',
deadline text NOT NULL DEFAULT '',
lang text NOT NULL DEFAULT 'de',
body jsonb NOT NULL,
visibility text NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
promoted_at timestamptz,
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS checklists_owner_idx
ON paliad.checklists (owner_id);
CREATE INDEX IF NOT EXISTS checklists_visibility_idx
ON paliad.checklists (visibility)
WHERE visibility IN ('firm', 'global');
CREATE INDEX IF NOT EXISTS checklists_regime_idx
ON paliad.checklists (regime);
COMMENT ON TABLE paliad.checklists IS
'User-authored checklist templates. Augments the static Go catalog '
'at read time via ChecklistCatalogService. Visibility levels: '
'private (owner only), shared (Slice B), firm (all authenticated), '
'global (admin-promoted into firm catalog — Slice B).';
-- ============================================================================
-- 2. paliad.can_see_checklist(_user_id, _checklist_id)
--
-- Pattern mirrors paliad.can_see_project / paliad.effective_project_admin
-- (mig 111): STABLE SECURITY DEFINER, single-statement, predicate-friendly.
--
-- Slice A only relies on the owner + firm/global branches. The shared
-- branch (matching against paliad.checklist_shares) is wired now so
-- Slice B doesn't need to replace the function — a NULL row count just
-- returns false. The table doesn't exist yet, so the EXISTS clause must
-- be guarded; we inline a NOT EXISTS check on pg_class so the function
-- body compiles cleanly on Slice A while staying ready for Slice B.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner can always see.
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.owner_id = _user_id
)
-- firm / global visibility: every authenticated user.
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.visibility IN ('firm', 'global')
);
$$;
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
'True iff the user owns the checklist OR the checklist visibility is '
'firm/global. Slice B extends this predicate with the explicit-share '
'path over paliad.checklist_shares.';
-- ============================================================================
-- 3. RLS on paliad.checklists.
-- ============================================================================
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
-- SELECT: owner OR visible via can_see_checklist.
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
CREATE POLICY checklists_select
ON paliad.checklists FOR SELECT TO authenticated
USING (paliad.can_see_checklist(auth.uid(), id));
-- INSERT: caller can only create templates owned by themselves.
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
CREATE POLICY checklists_insert
ON paliad.checklists FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- UPDATE: owner OR global_admin.
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
CREATE POLICY checklists_update
ON paliad.checklists FOR UPDATE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
)
WITH CHECK (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- DELETE: owner OR global_admin.
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
CREATE POLICY checklists_delete
ON paliad.checklists FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- ============================================================================
-- 4. paliad.checklist_instances.template_snapshot — instance integrity column.
--
-- Captures the template body (groups + items) at instance create time so
-- subsequent template edits / visibility narrowing don't affect existing
-- per-Akte instances. NULL on rows created before this migration; the
-- service layer falls back to live catalog lookup for those.
-- ============================================================================
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
COMMENT ON COLUMN paliad.checklist_instances.template_snapshot IS
'Snapshot of the template body at instance create time. NULL for '
'pre-mig-114 rows; service layer falls back to live catalog lookup '
'in that case (legacy path; backfilled in Slice C).';

View File

@@ -0,0 +1,26 @@
-- Reverse of mig 115 — t-paliad-225 / m/paliad#61 Slice B.
--
-- Restore the owner+firm/global-only body of paliad.can_see_checklist
-- (matches the mig 114 definition) so a rollback of Slice B leaves the
-- function pointing at the Slice A behaviour.
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.owner_id = _user_id
)
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
);
$$;
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
DROP TABLE IF EXISTS paliad.checklist_shares;

View File

@@ -0,0 +1,211 @@
-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing +
-- admin-promotion plumbing for user-authored checklists.
--
-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3
-- / §4.5.
--
-- Introduces paliad.checklist_shares with the polymorphic recipient
-- pattern (xor-check enforces exactly one recipient_* column populated
-- per recipient_kind). Extends paliad.can_see_checklist with the
-- explicit-share branches so the 'shared' visibility level actually
-- gates anything.
--
-- Sections:
-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS).
-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share
-- branches (user / office / partner_unit / project).
--
-- Idempotent throughout.
-- ============================================================================
-- 1. paliad.checklist_shares — explicit grants for a single checklist.
--
-- recipient_kind disambiguates which recipient_* column is populated.
-- The XOR check makes the constraint structurally enforce "exactly one
-- recipient_<kind> non-null per row". Per-kind UNIQUE partial indexes
-- prevent duplicate grants per (checklist, recipient).
--
-- Slice A's checklists.visibility CHECK already includes 'shared' so no
-- ALTER is needed here.
-- ============================================================================
CREATE TABLE IF NOT EXISTS paliad.checklist_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
recipient_kind text NOT NULL
CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
recipient_office text,
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT checklist_shares_recipient_xor CHECK (
(recipient_kind = 'user'
AND recipient_user_id IS NOT NULL
AND recipient_office IS NULL
AND recipient_partner_unit_id IS NULL
AND recipient_project_id IS NULL)
OR (recipient_kind = 'office'
AND recipient_office IS NOT NULL
AND recipient_user_id IS NULL
AND recipient_partner_unit_id IS NULL
AND recipient_project_id IS NULL)
OR (recipient_kind = 'partner_unit'
AND recipient_partner_unit_id IS NOT NULL
AND recipient_user_id IS NULL
AND recipient_office IS NULL
AND recipient_project_id IS NULL)
OR (recipient_kind = 'project'
AND recipient_project_id IS NOT NULL
AND recipient_user_id IS NULL
AND recipient_office IS NULL
AND recipient_partner_unit_id IS NULL)
)
);
-- Hot-path lookup for the visibility predicate.
CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx
ON paliad.checklist_shares (checklist_id);
-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_<other>
-- doesn't collide with another row's NULL recipient_<other>.
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq
ON paliad.checklist_shares (checklist_id, recipient_user_id)
WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq
ON paliad.checklist_shares (checklist_id, recipient_office)
WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq
ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id)
WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq
ON paliad.checklist_shares (checklist_id, recipient_project_id)
WHERE recipient_kind = 'project';
COMMENT ON TABLE paliad.checklist_shares IS
'Explicit grants for paliad.checklists. Polymorphic recipient '
'(user/office/partner_unit/project) enforced by recipient_xor CHECK. '
'Owner of the checklist grants and revokes; global_admin can revoke '
'as well. Slice B (t-paliad-225) — see can_see_checklist body for '
'the visibility branches that consume these rows.';
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
-- SELECT: caller can see the row if they own the parent checklist OR
-- they are the recipient (for user-kind grants — recipients shouldn't
-- be surprised by who else can also see the checklist) OR global_admin.
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
CREATE POLICY checklist_shares_select
ON paliad.checklist_shares FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
)
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- INSERT: only the checklist owner can grant; granted_by must be self.
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
CREATE POLICY checklist_shares_insert
ON paliad.checklist_shares FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
)
AND granted_by = auth.uid()
);
-- DELETE: owner OR global_admin. No UPDATE policy — grants are
-- immutable, revoke = DELETE + re-insert with the corrected recipient.
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
CREATE POLICY checklist_shares_delete
ON paliad.checklist_shares FOR DELETE TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
)
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- ============================================================================
-- 2. paliad.can_see_checklist — extend with the 4 share branches.
--
-- Owner + firm/global branches stay as in mig 114. Share branches:
-- - user — the row's recipient_user_id matches the caller
-- - office — recipient_office matches caller's office OR is in
-- their additional_offices array
-- - partner_unit — caller is a member of the recipient partner_unit
-- - project — caller can see the recipient project (reuses
-- paliad.can_see_project, ltree-walked)
--
-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance
-- (same pattern effective_project_admin uses in mig 111).
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.owner_id = _user_id
)
-- firm / global
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
)
-- Explicit share: user
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = _user_id
)
-- Explicit share: office (caller's primary OR additional offices)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
-- Explicit share: partner_unit (caller is a member)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'partner_unit'
)
-- Explicit share: project (caller can see the project via existing predicate)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'project'
AND paliad.can_see_project(s.recipient_project_id)
);
$$;
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
'True iff the user owns the checklist OR firm/global visibility OR '
'an explicit share row matches the caller (by user / office / '
'partner_unit / project ancestry).';

View File

@@ -0,0 +1,7 @@
-- Reverse of mig 116 — t-paliad-225 / m/paliad#61 Slice C.
ALTER TABLE paliad.checklist_instances
DROP COLUMN IF EXISTS template_version;
ALTER TABLE paliad.checklists
DROP COLUMN IF EXISTS version;

View File

@@ -0,0 +1,39 @@
-- mig 116 — t-paliad-225 / m/paliad#61 Slice C — template versioning.
--
-- Design: docs/design-user-checklists-2026-05-20.md §3.4 / §6.
--
-- Adds an integer version counter to paliad.checklists that bumps on
-- every meaningful edit (body or title — see
-- ChecklistTemplateService.Update). Adds a matching template_version
-- column on paliad.checklist_instances so the instance detail page can
-- surface "the template you instantiated from has been updated" and
-- offer a diff view.
--
-- Existing rows backfill to version=1 / template_version=NULL. The
-- NULL on instances means "we don't know which version was snapshotted"
-- (pre-Slice-C row); the snapshot column is still authoritative for
-- rendering, but the "outdated" badge stays off because we can't
-- compare.
--
-- Idempotent throughout.
ALTER TABLE paliad.checklists
ADD COLUMN IF NOT EXISTS version int NOT NULL DEFAULT 1;
-- Backfill any rows that somehow ended up at 0 (shouldn't happen with
-- DEFAULT 1, but defensive — the column was added with default so this
-- is a no-op on the live DB).
UPDATE paliad.checklists SET version = 1 WHERE version < 1;
COMMENT ON COLUMN paliad.checklists.version IS
'Monotonic version counter, bumps in ChecklistTemplateService.Update '
'whenever body or title changes. Used by the instance detail page '
'to show an "outdated" badge when the user''s snapshot is older.';
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_version int;
COMMENT ON COLUMN paliad.checklist_instances.template_version IS
'Snapshot of paliad.checklists.version at instance create time. '
'NULL for pre-Slice-C rows where the version wasn''t captured; the '
'"outdated" badge stays off in that case.';

View File

@@ -44,6 +44,78 @@ func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
// POST /api/admin/users/full — create BOTH an auth.users row (via Supabase
// Admin API) and a paliad.users row in one operation. t-paliad-223 Slice B
// (#49). Lets a global_admin onboard a colleague without forcing them
// through the email-invitation round-trip; the new user is visible in
// dropdowns immediately and can log in via the emailed magic-link.
//
// Requires SUPABASE_SERVICE_ROLE_KEY at the server. Returns 503 when
// unset so a deploy that hasn't provisioned the credential yet gets a
// clear diagnostic instead of a cryptic 500.
//
// Error mapping:
// - ErrSupabaseAdminUnavailable → 503
// - ErrSupabaseEmailExists → 409 (hint to use "Onboard existing")
// - ErrUserAlreadyOnboarded → 409 (paliad.users dup; should be unreachable)
// - ErrInvalidInput → 400 (bad shape)
// - email domain not on whitelist → 403 (mirrors handleAdminCreateUser)
// - other → 500
func handleAdminCreateFullUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.AdminCreateFullInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if !isAllowedEmailDomain(input.Email) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "email domain not on the " + branding.Name + " allow-list",
})
return
}
// Look up the inviter (the calling admin) so the welcome email and
// audit row carry their identity. Failures here shouldn't block the
// create; we just degrade to empty fields.
inviter, err := dbSvc.users.GetByID(r.Context(), uid)
if err == nil && inviter != nil {
input.InviterID = inviter.ID
input.InviterName = inviter.DisplayName
input.InviterEmail = inviter.Email
}
u, err := dbSvc.users.AdminCreateUserFull(r.Context(), input)
if err != nil {
switch {
case errors.Is(err, services.ErrSupabaseAdminUnavailable):
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "add-user flow requires SUPABASE_SERVICE_ROLE_KEY on the server",
})
case errors.Is(err, services.ErrSupabaseEmailExists):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "auth account already exists — please use 'Onboard existing' instead",
})
case errors.Is(err, services.ErrUserAlreadyOnboarded):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "user already onboarded",
})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
return
}
writeJSON(w, http.StatusCreated, u)
}
// POST /api/admin/users — direct-create a paliad.users row for an existing
// auth.users entry. The recipient email's domain must already match the
// allowed-email policy (Supabase wouldn't have let them sign up otherwise),

View File

@@ -24,8 +24,13 @@ func handleAppointmentsDetailPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/appointments-detail.html")
}
// handleAppointmentsCalendarPage 301-redirects the legacy standalone
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
// m/paliad#55). Counterpart of handleDeadlinesCalendarPage — same
// reasoning: the standalone page was orphaned in navigation since
// t-paliad-110, the canonical calendar lives inside /events.
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/appointments-calendar.html")
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
}
// handleSettingsPage serves the unified settings page with tabs for

View File

@@ -0,0 +1,131 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/checklists/templates/{slug}/shares — list grants (owner/admin).
func handleListChecklistShares(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
rows, err := dbSvc.checklistShare.ListGrants(r.Context(), uid, slug)
if err != nil {
writeChecklistShareError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/checklists/templates/{slug}/shares — grant a share.
func handleGrantChecklistShare(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var input services.ShareGrantInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
share, err := dbSvc.checklistShare.Grant(r.Context(), uid, slug, input)
if err != nil {
writeChecklistShareError(w, err)
return
}
writeJSON(w, http.StatusCreated, share)
}
// DELETE /api/checklists/shares/{id} — revoke a share by id.
func handleRevokeChecklistShare(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.checklistShare.Revoke(r.Context(), uid, id); err != nil {
writeChecklistShareError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/admin/checklists/{slug}/promote — global_admin only.
func handlePromoteChecklist(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
if err := dbSvc.checklistPromotion.Promote(r.Context(), uid, slug); err != nil {
writeChecklistShareError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/admin/checklists/{slug}/demote — global_admin only.
func handleDemoteChecklist(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var body struct {
Target string `json:"target"`
}
// Body is optional — Demote defaults to 'firm' when empty.
_ = json.NewDecoder(r.Body).Decode(&body)
if err := dbSvc.checklistPromotion.Demote(r.Context(), uid, slug, body.Target); err != nil {
writeChecklistShareError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeChecklistShareError maps the share/promotion service errors.
// Same as the templates handler: ErrInvalidInput → 400, ErrForbidden →
// 403, ErrNotVisible → 404, fall through to writeServiceError.
func writeChecklistShareError(w http.ResponseWriter, err error) {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if errors.Is(err, services.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
if errors.Is(err, services.ErrNotVisible) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
return
}
writeServiceError(w, err)
}

View File

@@ -0,0 +1,133 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/checklists/templates/mine — list authored templates owned by caller.
func handleListMyChecklistTemplates(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.checklistTemplate.ListOwnedBy(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/checklists/templates — create a new authored template.
func handleCreateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.checklistTemplate.Create(r.Context(), uid, input)
if err != nil {
writeChecklistTemplateError(w, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
// PATCH /api/checklists/templates/{slug} — update authored template (owner only).
func handleUpdateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var input services.UpdateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.checklistTemplate.Update(r.Context(), uid, slug, input)
if err != nil {
writeChecklistTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// PATCH /api/checklists/templates/{slug}/visibility — toggle private↔firm.
func handleSetChecklistTemplateVisibility(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var body struct {
Visibility string `json:"visibility"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.checklistTemplate.SetVisibility(r.Context(), uid, slug, body.Visibility)
if err != nil {
writeChecklistTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// DELETE /api/checklists/templates/{slug} — delete authored template.
func handleDeleteChecklistTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
if err := dbSvc.checklistTemplate.Delete(r.Context(), uid, slug); err != nil {
writeChecklistTemplateError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeChecklistTemplateError maps service errors to HTTP status. Falls
// through to writeServiceError for unknown errors so the generic
// ErrNotVisible / ErrInvalidInput / ErrForbidden mappings still apply.
func writeChecklistTemplateError(w http.ResponseWriter, err error) {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": strings.TrimPrefix(err.Error(), "invalid input: ")})
return
}
if errors.Is(err, services.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
if errors.Is(err, services.ErrNotVisible) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
return
}
writeServiceError(w, err)
}

View File

@@ -24,6 +24,13 @@ func handleChecklistsPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists.html")
}
// handleChecklistsAuthorPage serves the authoring wizard (new + edit
// share the same bundle; the client reads location.pathname to decide
// create vs edit mode).
func handleChecklistsAuthorPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists-author.html")
}
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if _, ok := checklists.Find(slug); !ok {
@@ -37,18 +44,105 @@ func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists-instance.html")
}
// handleChecklistsAPI returns the merged catalog: static templates
// (always) plus authored DB templates the caller can see (mig 114).
// Each entry carries origin + visibility + author metadata so the
// frontend can render provenance.
//
// Falls back to the bare static catalog when DB is unavailable so the
// knowledge-platform-only deploy stays functional without DATABASE_URL.
func handleChecklistsAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, checklists.Summaries())
if dbSvc == nil || dbSvc.checklistCatalog == nil {
// Fall back to static summaries shape so the existing frontend
// keeps working in the no-DB deploy.
writeJSON(w, http.StatusOK, checklists.Summaries())
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
entries, err := dbSvc.checklistCatalog.ListVisible(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
// Frontend expects the existing Summary shape on the index list; map
// the merged entries to Summary + origin/visibility/author fields.
type Summary struct {
checklists.Summary
Origin string `json:"origin"`
Visibility string `json:"visibility"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
}
out := make([]Summary, 0, len(entries))
for _, e := range entries {
out = append(out, Summary{
Summary: checklists.Summary{
Slug: e.Template.Slug,
TitleDE: e.Template.TitleDE,
TitleEN: e.Template.TitleEN,
DescriptionDE: e.Template.DescriptionDE,
DescriptionEN: e.Template.DescriptionEN,
Regime: e.Template.Regime,
CourtDE: e.Template.CourtDE,
CourtEN: e.Template.CourtEN,
ItemCount: checklists.TotalItems(e.Template),
},
Origin: e.Origin,
Visibility: e.Visibility,
OwnerEmail: e.OwnerEmail,
OwnerDisplayName: e.OwnerDisplayName,
})
}
writeJSON(w, http.StatusOK, out)
}
// handleChecklistAPI returns one template by slug. Looks up static
// catalog first (always visible), then authored DB rows via the
// catalog with visibility check.
func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
c, ok := checklists.Find(slug)
if !ok {
// Static-first path keeps the no-DB deploy functional and is the
// common case for the curated templates.
if c, ok := checklists.Find(slug); ok {
writeJSON(w, http.StatusOK, c)
return
}
if dbSvc == nil || dbSvc.checklistCatalog == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
return
}
writeJSON(w, http.StatusOK, c)
uid, ok := requireUser(w, r)
if !ok {
return
}
entry, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
return
}
// Re-render as the bilingual Template shape plus a thin meta block.
// Version is included so the instance detail page can decide whether
// to show the "template updated since this instance was created"
// badge (Slice C).
type templateWithMeta struct {
checklists.Template
Origin string `json:"origin"`
Visibility string `json:"visibility"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
Version int `json:"version"`
}
writeJSON(w, http.StatusOK, templateWithMeta{
Template: entry.Template,
Origin: entry.Origin,
Visibility: entry.Visibility,
OwnerEmail: entry.OwnerEmail,
OwnerDisplayName: entry.OwnerDisplayName,
Version: entry.Version,
})
}
func handleChecklistsFeedback(w http.ResponseWriter, r *http.Request) {

View File

@@ -23,6 +23,13 @@ func handleDeadlinesDetailPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/deadlines-detail.html")
}
// handleDeadlinesCalendarPage 301-redirects the legacy standalone
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
// m/paliad#55). The standalone page was orphaned in navigation since
// t-paliad-110 — Sidebar/BottomNav already point at /events?type=…, and
// the canonical calendar lives inside that page's view chip. The
// redirect preserves bookmarks and external links without a duplicate
// rendering pipeline.
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/deadlines-calendar.html")
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
}

View File

@@ -70,7 +70,11 @@ type Services struct {
EventType *services.EventTypeService
Dashboard *services.DashboardService
Note *services.NoteService
ChecklistInst *services.ChecklistInstanceService
ChecklistInst *services.ChecklistInstanceService
ChecklistCatalog *services.ChecklistCatalogService
ChecklistTemplate *services.ChecklistTemplateService
ChecklistShare *services.ChecklistShareService
ChecklistPromotion *services.ChecklistPromotionService
Mail *services.MailService
Invite *services.InviteService
Agenda *services.AgendaService
@@ -144,7 +148,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
eventType: svc.EventType,
dashboard: svc.Dashboard,
note: svc.Note,
checklistInst: svc.ChecklistInst,
checklistInst: svc.ChecklistInst,
checklistCatalog: svc.ChecklistCatalog,
checklistTemplate: svc.ChecklistTemplate,
checklistShare: svc.ChecklistShare,
checklistPromotion: svc.ChecklistPromotion,
mail: svc.Mail,
invite: svc.Invite,
agenda: svc.Agenda,
@@ -248,11 +256,25 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/tools/gebuehrentabellen/lookup", handleGebuehrentabellenLookup)
protected.HandleFunc("POST /api/tools/gebuehrentabellen/feedback", handleGebuehrentabellenFeedback)
protected.HandleFunc("GET /checklists", handleChecklistsPage)
protected.HandleFunc("GET /checklists/new", handleChecklistsAuthorPage)
protected.HandleFunc("GET /checklists/instances/{id}", handleChecklistInstancePage)
protected.HandleFunc("GET /checklists/templates/{slug}/edit", handleChecklistsAuthorPage)
protected.HandleFunc("GET /checklists/{slug}", handleChecklistDetailPage)
protected.HandleFunc("GET /api/checklists", handleChecklistsAPI)
protected.HandleFunc("GET /api/checklists/{slug}", handleChecklistAPI)
protected.HandleFunc("POST /api/checklists/feedback", handleChecklistsFeedback)
// t-paliad-225 Slice A — user-authored templates (paliad.checklists).
protected.HandleFunc("GET /api/checklists/templates/mine", handleListMyChecklistTemplates)
protected.HandleFunc("POST /api/checklists/templates", handleCreateChecklistTemplate)
protected.HandleFunc("PATCH /api/checklists/templates/{slug}", handleUpdateChecklistTemplate)
protected.HandleFunc("PATCH /api/checklists/templates/{slug}/visibility", handleSetChecklistTemplateVisibility)
protected.HandleFunc("DELETE /api/checklists/templates/{slug}", handleDeleteChecklistTemplate)
// t-paliad-225 Slice B — explicit sharing + admin promotion.
protected.HandleFunc("GET /api/checklists/templates/{slug}/shares", handleListChecklistShares)
protected.HandleFunc("POST /api/checklists/templates/{slug}/shares", handleGrantChecklistShare)
protected.HandleFunc("DELETE /api/checklists/shares/{id}", handleRevokeChecklistShare)
protected.HandleFunc("POST /api/admin/checklists/{slug}/promote", handlePromoteChecklist)
protected.HandleFunc("POST /api/admin/checklists/{slug}/demote", handleDemoteChecklist)
protected.HandleFunc("GET /api/checklists/{slug}/instances", handleListChecklistInstancesForTemplate)
protected.HandleFunc("POST /api/checklists/{slug}/instances", handleCreateChecklistInstance)
protected.HandleFunc("GET /api/checklist-instances", handleListAllChecklistInstances)
@@ -509,6 +531,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
protected.HandleFunc("PATCH /api/admin/users/{id}", adminGate(users, handleAdminUpdateUser))
protected.HandleFunc("DELETE /api/admin/users/{id}", adminGate(users, handleAdminDeleteUser))

View File

@@ -38,7 +38,11 @@ type dbServices struct {
eventType *services.EventTypeService
dashboard *services.DashboardService
note *services.NoteService
checklistInst *services.ChecklistInstanceService
checklistInst *services.ChecklistInstanceService
checklistCatalog *services.ChecklistCatalogService
checklistTemplate *services.ChecklistTemplateService
checklistShare *services.ChecklistShareService
checklistPromotion *services.ChecklistPromotionService
mail *services.MailService
invite *services.InviteService
agenda *services.AgendaService

View File

@@ -32,3 +32,28 @@ func TestRegisterLegacyRedirects_SubProjectsAlias(t *testing.T) {
}
}
}
// t-paliad-224: /deadlines/calendar and /appointments/calendar 301 to
// the canonical /events Kalender tab. Pins the redirect target so a
// future refactor doesn't silently break the bookmark contract.
func TestStandaloneCalendarHandlers_RedirectToEventsKalender(t *testing.T) {
cases := []struct {
name string
handler http.HandlerFunc
want string
}{
{"deadlines", handleDeadlinesCalendarPage, "/events?type=deadline&view=calendar"},
{"appointments", handleAppointmentsCalendarPage, "/events?type=appointment&view=calendar"},
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, "/x", nil) // path ignored — handler is direct
w := httptest.NewRecorder()
tc.handler(w, req)
if w.Code != http.StatusMovedPermanently {
t.Fatalf("%s: status = %d, want %d", tc.name, w.Code, http.StatusMovedPermanently)
}
if got := w.Header().Get("Location"); got != tc.want {
t.Fatalf("%s: Location = %q, want %q", tc.name, got, tc.want)
}
}
}

View File

@@ -421,22 +421,32 @@ type Note struct {
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
}
// ChecklistInstance is one user's instantiation of a static checklist
// template (defined in internal/checklists). Checkbox state lives in the
// `state` jsonb column.
// ChecklistInstance is one user's instantiation of a checklist template
// (static catalog in internal/checklists OR authored row in
// paliad.checklists). Checkbox state lives in the `state` jsonb column.
//
// Visibility mirrors Appointment: project_id nullable. Personal instances
// (project_id NULL) are creator-only; Project-linked instances follow
// paliad.can_see_project.
//
// TemplateSnapshot captures the template body at instance create time so
// subsequent template edits / visibility narrowing don't affect existing
// instances (t-paliad-225 Slice A). NULL on pre-mig-114 rows; the
// service layer falls back to live catalog lookup in that case.
type ChecklistInstance struct {
ID uuid.UUID `db:"id" json:"id"`
TemplateSlug string `db:"template_slug" json:"template_slug"`
Name string `db:"name" json:"name"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
State json.RawMessage `db:"state" json:"state"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
TemplateSlug string `db:"template_slug" json:"template_slug"`
Name string `db:"name" json:"name"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
State json.RawMessage `db:"state" json:"state"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
TemplateSnapshot NullableJSON `db:"template_snapshot" json:"template_snapshot,omitempty"`
// TemplateVersion is the checklists.version at instance create time.
// NULL on pre-Slice-C rows where versioning wasn't captured; the
// "outdated" badge stays off in that case.
TemplateVersion *int `db:"template_version" json:"template_version,omitempty"`
}
// ChecklistInstanceWithProject enriches an instance with its parent Project
@@ -447,6 +457,37 @@ type ChecklistInstanceWithProject struct {
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
}
// Checklist is one authored template row in paliad.checklists. Augments
// the static Go catalog (internal/checklists/templates.go) at read time
// via ChecklistCatalogService. Body holds the groups + items as JSONB.
type Checklist struct {
ID uuid.UUID `db:"id" json:"id"`
Slug string `db:"slug" json:"slug"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
Regime string `db:"regime" json:"regime"`
Court string `db:"court" json:"court"`
Reference string `db:"reference" json:"reference"`
Deadline string `db:"deadline" json:"deadline"`
Lang string `db:"lang" json:"lang"`
Body json.RawMessage `db:"body" json:"body"`
Visibility string `db:"visibility" json:"visibility"`
PromotedAt *time.Time `db:"promoted_at" json:"promoted_at,omitempty"`
PromotedBy *uuid.UUID `db:"promoted_by" json:"promoted_by,omitempty"`
Version int `db:"version" json:"version"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ChecklistWithOwner enriches a Checklist row with author display fields
// for list views (Meine Vorlagen, Gallery).
type ChecklistWithOwner struct {
Checklist
OwnerEmail string `db:"owner_email" json:"owner_email"`
OwnerDisplayName string `db:"owner_display_name" json:"owner_display_name"`
}
// UserCalDAVConfig holds one user's external CalDAV connection. The password
// is never returned in API responses; only the public fields are exposed.
type UserCalDAVConfig struct {

View File

@@ -0,0 +1,309 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
// ChecklistCatalogService unifies the static Go template catalog
// (internal/checklists/templates.go) and the user-authored DB catalog
// (paliad.checklists, mig 114) into a single read facade.
//
// Slug uniqueness is enforced across both spaces at write time by
// ChecklistTemplateService (authored slugs get a 'u-' prefix and we
// reject collisions with static slugs). Catalog lookups prefer static
// templates on collision so a stray DB row never shadows curated
// content — see Find().
type ChecklistCatalogService struct {
db *sqlx.DB
staticSlugs map[string]bool
}
// NewChecklistCatalogService wires the service and pre-computes the
// static-slug set used for collision detection at write + read time.
func NewChecklistCatalogService(db *sqlx.DB) *ChecklistCatalogService {
set := make(map[string]bool, len(checklists.Templates))
for _, t := range checklists.Templates {
set[t.Slug] = true
}
return &ChecklistCatalogService{db: db, staticSlugs: set}
}
// CatalogEntry is one unified entry — either a static template or an
// authored DB row. Origin identifies the source so the UI can render
// provenance ("Erstellt von <author>" for authored, plain title for
// static).
type CatalogEntry struct {
Slug string `json:"slug"`
Origin string `json:"origin"` // "static" | "authored"
Visibility string `json:"visibility"`
OwnerID *uuid.UUID `json:"owner_id,omitempty"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
// Version of the underlying row. 1 for static templates (they
// re-version implicitly with the deploy that ships them); the live
// counter from paliad.checklists.version for authored rows.
Version int `json:"version"`
Template checklists.Template `json:"template"`
}
// IsStaticSlug reports whether the given slug names a curated static
// template. Called by ChecklistTemplateService.Create to reject author
// slugs that would shadow a curated entry.
func (s *ChecklistCatalogService) IsStaticSlug(slug string) bool {
return s.staticSlugs[slug]
}
// ListVisible returns every catalog entry the caller can see — every
// static template (always visible) plus every authored DB row that
// passes paliad.can_see_checklist via RLS.
//
// Ordering: static templates first in their definition order, then
// authored rows alphabetised by title.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error) {
out := make([]CatalogEntry, 0, len(checklists.Templates))
for _, t := range checklists.Templates {
out = append(out, CatalogEntry{
Slug: t.Slug,
Origin: "static",
Visibility: "static",
Version: 1,
Template: t,
})
}
if s.db == nil {
return out, nil
}
rows, err := s.fetchVisibleAuthored(ctx, userID)
if err != nil {
return nil, err
}
sort.SliceStable(rows, func(i, j int) bool {
return strings.ToLower(rows[i].Title) < strings.ToLower(rows[j].Title)
})
for _, r := range rows {
// Skip the row if it collides with a static slug — static wins.
if s.staticSlugs[r.Slug] {
continue
}
tpl, err := s.rowToTemplate(r)
if err != nil {
return nil, err
}
ownerID := r.OwnerID
out = append(out, CatalogEntry{
Slug: r.Slug,
Origin: "authored",
Visibility: r.Visibility,
OwnerID: &ownerID,
OwnerEmail: r.OwnerEmail,
OwnerDisplayName: r.OwnerDisplayName,
Version: r.Version,
Template: tpl,
})
}
return out, nil
}
// Find resolves a slug to a single catalog entry, applying visibility
// (RLS for authored rows; static always visible). Returns ErrNotVisible
// if the slug is unknown or the caller can't see the authored row.
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error) {
if t, ok := checklists.Find(slug); ok {
return &CatalogEntry{
Slug: t.Slug,
Origin: "static",
Visibility: "static",
Version: 1,
Template: t,
}, nil
}
if s.db == nil {
return nil, ErrNotVisible
}
row, err := s.fetchAuthoredBySlug(ctx, userID, slug)
if err != nil {
return nil, err
}
if row == nil {
return nil, ErrNotVisible
}
tpl, err := s.rowToTemplate(*row)
if err != nil {
return nil, err
}
ownerID := row.OwnerID
return &CatalogEntry{
Slug: row.Slug,
Origin: "authored",
Visibility: row.Visibility,
OwnerID: &ownerID,
OwnerEmail: row.OwnerEmail,
OwnerDisplayName: row.OwnerDisplayName,
Version: row.Version,
Template: tpl,
}, nil
}
// SnapshotBody returns the template body as JSONB suitable for storing
// in paliad.checklist_instances.template_snapshot. For static templates
// we marshal the full Template struct; for authored rows we return the
// body column directly (it already has the right shape — groups[]).
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error) {
entry, err := s.Find(ctx, userID, slug)
if err != nil {
return nil, err
}
body, err := json.Marshal(entry.Template)
if err != nil {
return nil, fmt.Errorf("snapshot marshal: %w", err)
}
return body, nil
}
// --- internals ------------------------------------------------------------
const authoredWithOwnerSelect = `SELECT c.id, c.slug, c.owner_id, c.title, c.description,
c.regime, c.court, c.reference, c.deadline, c.lang, c.body, c.visibility,
c.promoted_at, c.promoted_by, c.version, c.created_at, c.updated_at,
u.email AS owner_email,
u.display_name AS owner_display_name
FROM paliad.checklists c
JOIN paliad.users u ON u.id = c.owner_id`
// checklistVisibilityPredicate mirrors paliad.can_see_checklist for the
// service-role connection (which bypasses RLS). Covers all 6 branches
// from mig 115: owner + firm/global + global_admin + 4 share-recipient
// kinds (user / office / partner_unit / project).
//
// One positional arg ($userArg) for the caller UUID. Reused several
// times across the branches; that's fine — Postgres positional
// placeholders evaluate the arg once per reference, no extra param
// binding overhead.
func checklistVisibilityPredicate(alias string, userArg int) string {
return fmt.Sprintf(`(%s.owner_id = $%d
OR %s.visibility IN ('firm', 'global')
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $%d AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = $%d
)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = $%d
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = $%d
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'partner_unit'
)
OR EXISTS (
-- Share-to-project resolution: inline ltree walk over
-- paliad.projects.path because paliad.can_see_project
-- reads auth.uid() which is NULL on the service-role
-- connection (same pattern as visibility.go).
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.projects p
ON p.id = s.recipient_project_id
JOIN paliad.project_teams pt
ON pt.user_id = $%d
AND pt.project_id = ANY(CAST(string_to_array(p.path, '.') AS uuid[]))
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'project'
))`,
alias, userArg, // owner
alias, // firm/global visibility col
userArg, // global_admin
alias, userArg, // share: user
userArg, alias, // share: office
userArg, alias, // share: partner_unit
userArg, alias, // share: project (ltree walk)
)
}
func (s *ChecklistCatalogService) fetchVisibleAuthored(ctx context.Context, userID uuid.UUID) ([]models.ChecklistWithOwner, error) {
rows := []models.ChecklistWithOwner{}
q := authoredWithOwnerSelect + `
WHERE ` + checklistVisibilityPredicate("c", 1) + `
ORDER BY c.title ASC`
if err := s.db.SelectContext(ctx, &rows, q, userID); err != nil {
return nil, fmt.Errorf("list authored checklists: %w", err)
}
return rows, nil
}
func (s *ChecklistCatalogService) fetchAuthoredBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.ChecklistWithOwner, error) {
var row models.ChecklistWithOwner
q := authoredWithOwnerSelect + `
WHERE c.slug = $2
AND ` + checklistVisibilityPredicate("c", 1)
err := s.db.GetContext(ctx, &row, q, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("fetch authored checklist: %w", err)
}
return &row, nil
}
func (s *ChecklistCatalogService) rowToTemplate(row models.ChecklistWithOwner) (checklists.Template, error) {
// body jsonb holds { "groups": [...] }. Unmarshal into a thin local
// shape because the full checklists.Template has DE/EN sibling
// fields the author only fills one side of.
var bodyShape struct {
Groups []checklists.Group `json:"groups"`
}
if err := json.Unmarshal(row.Body, &bodyShape); err != nil {
return checklists.Template{}, fmt.Errorf("unmarshal authored body for %s: %w", row.Slug, err)
}
t := checklists.Template{
Slug: row.Slug,
Regime: row.Regime,
Groups: bodyShape.Groups,
ReferenceDE: row.Reference,
ReferenceEN: row.Reference,
DeadlineDE: row.Deadline,
DeadlineEN: row.Deadline,
CourtDE: row.Court,
CourtEN: row.Court,
}
// Author picks one language per template — surface their title /
// description on both sides so the existing bilingual frontend
// renders without a special-case for authored entries.
t.TitleDE = row.Title
t.TitleEN = row.Title
t.DescriptionDE = row.Description
t.DescriptionEN = row.Description
return t, nil
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -21,17 +20,23 @@ import (
// Visibility mirrors paliad.appointments (project_id nullable):
// - project_id NULL → creator-only (personal instance)
// - project_id NOT NULL → parent Project's team-based gate
//
// Template resolution goes through ChecklistCatalogService so authored
// templates (paliad.checklists, mig 114) and static templates work
// interchangeably. Instance create captures a template_snapshot so
// subsequent template edits/deletes don't disturb existing instances.
type ChecklistInstanceService struct {
db *sqlx.DB
projects *ProjectService
catalog *ChecklistCatalogService
}
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projects: projects}
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService, catalog *ChecklistCatalogService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projects: projects, catalog: catalog}
}
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state,
ci.created_by, ci.created_at, ci.updated_at`
ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot, ci.template_version`
const checklistInstanceWithProjectSelect = `SELECT ` + checklistInstanceColumns + `,
p.reference AS project_reference,
@@ -55,8 +60,11 @@ type UpdateInstanceInput struct {
// ListForTemplate returns every visible instance of a given template.
func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProject, error) {
if _, ok := checklists.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
if _, err := s.catalog.Find(ctx, userID, slug); err != nil {
if errors.Is(err, ErrNotVisible) {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
return nil, err
}
user, err := s.projects.Users().GetByID(ctx, userID)
if err != nil {
@@ -124,11 +132,25 @@ func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.
return inst, nil
}
// Create inserts a new instance.
// Create inserts a new instance. Captures a template_snapshot via the
// catalog so subsequent template edits/deletes don't disturb this row
// (t-paliad-225 Slice A).
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
if _, ok := checklists.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
entry, err := s.catalog.Find(ctx, userID, slug)
if err != nil {
if errors.Is(err, ErrNotVisible) {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
return nil, err
}
snapshot, err := s.catalog.SnapshotBody(ctx, userID, slug)
if err != nil {
return nil, fmt.Errorf("snapshot template body: %w", err)
}
// Slice C — capture the version we snapshotted from so the instance
// detail page can show "template updated since this instance was
// created" when the live version pulls ahead.
snapshotVersion := entry.Version
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
@@ -153,9 +175,10 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.checklist_instances
(id, template_slug, name, project_id, state, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, $6, $6)`,
id, slug, name, input.ProjectID, userID, now,
(id, template_slug, name, project_id, state, template_snapshot,
template_version, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5::jsonb, $6, $7, $8, $8)`,
id, slug, name, input.ProjectID, string(snapshot), snapshotVersion, userID, now,
); err != nil {
return nil, fmt.Errorf("insert checklist_instance: %w", err)
}
@@ -366,7 +389,8 @@ func (s *ChecklistInstanceService) listWithProject(ctx context.Context, query st
func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.ChecklistInstance, error) {
var inst models.ChecklistInstance
err := s.db.GetContext(ctx, &inst,
`SELECT id, template_slug, name, project_id, state, created_by, created_at, updated_at
`SELECT id, template_slug, name, project_id, state, created_by,
created_at, updated_at, template_snapshot, template_version
FROM paliad.checklist_instances WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible

View File

@@ -0,0 +1,153 @@
package services
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ChecklistPromotionService implements the global_admin-only promote /
// demote flow for paliad.checklists. Promote flips visibility to
// 'global' and stamps promoted_at / promoted_by; demote flips it back
// to a caller-chosen target ('firm' default — preserves visibility for
// already-instantiated users).
type ChecklistPromotionService struct {
db *sqlx.DB
templates *ChecklistTemplateService
audit *SystemAuditLogService
users *UserService
}
func NewChecklistPromotionService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistPromotionService {
return &ChecklistPromotionService{db: db, templates: templates, audit: audit, users: users}
}
// validDemoteTargets — narrowing the visibility back from 'global' is
// only allowed to a state where the row is still meaningful. 'global'
// would be a no-op; 'shared' would orphan existing instance owners who
// already see it without a grant. Default is 'firm'.
var validDemoteTargets = map[string]bool{"firm": true, "private": true}
// Promote flips an authored template to visibility='global'. Caller
// must be global_admin. Emits 'checklist.promoted_global' audit event
// with the prior visibility captured for the demote-undo path.
func (s *ChecklistPromotionService) Promote(ctx context.Context, callerID uuid.UUID, slug string) error {
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
return err
}
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return err
}
if row.Visibility == "global" {
return nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin promote tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklists
SET visibility = 'global',
promoted_at = $2,
promoted_by = $3,
updated_at = $2
WHERE id = $1`, row.ID, time.Now().UTC(), callerID); err != nil {
return fmt.Errorf("promote checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.promoted_global",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"owner_id": row.OwnerID,
"prior_visibility": row.Visibility,
},
}); err != nil {
return err
}
return tx.Commit()
}
// Demote narrows visibility from 'global' to target. target defaults to
// 'firm' when empty. promoted_at / promoted_by are cleared.
func (s *ChecklistPromotionService) Demote(ctx context.Context, callerID uuid.UUID, slug, target string) error {
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
return err
}
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return err
}
t := strings.ToLower(strings.TrimSpace(target))
if t == "" {
t = "firm"
}
if !validDemoteTargets[t] {
return fmt.Errorf("%w: demote target must be firm | private, got %q", ErrInvalidInput, target)
}
if row.Visibility != "global" {
return fmt.Errorf("%w: checklist is not currently promoted (visibility=%s)", ErrInvalidInput, row.Visibility)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin demote tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklists
SET visibility = $2,
promoted_at = NULL,
promoted_by = NULL,
updated_at = now()
WHERE id = $1`, row.ID, t); err != nil {
return fmt.Errorf("demote checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.demoted",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"target_visibility": t,
},
}); err != nil {
return err
}
return tx.Commit()
}
func (s *ChecklistPromotionService) requireGlobalAdmin(ctx context.Context, callerID uuid.UUID) error {
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return err
}
if user == nil || user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only global_admin can promote / demote checklists", ErrForbidden)
}
return nil
}
func (s *ChecklistPromotionService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
u, err := s.users.GetByID(ctx, userID)
if err != nil || u == nil {
return "", err
}
return u.Email, nil
}

View File

@@ -0,0 +1,331 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/offices"
)
// ChecklistShareService is the write surface for paliad.checklist_shares
// (mig 115). Owners grant; owner-or-global_admin revokes. ListGrants is
// owner-only (returning all 4 recipient kinds) — recipients see "this
// is shared with me" only implicitly via the visibility predicate.
type ChecklistShareService struct {
db *sqlx.DB
templates *ChecklistTemplateService
audit *SystemAuditLogService
users *UserService
}
func NewChecklistShareService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistShareService {
return &ChecklistShareService{db: db, templates: templates, audit: audit, users: users}
}
// ShareGrantInput is the POST body for granting a share. Exactly one
// of the recipient_* fields must be set, matching recipient_kind.
type ShareGrantInput struct {
RecipientKind string `json:"recipient_kind"`
UserID *uuid.UUID `json:"recipient_user_id,omitempty"`
Office string `json:"recipient_office,omitempty"`
PartnerUnitID *uuid.UUID `json:"recipient_partner_unit_id,omitempty"`
ProjectID *uuid.UUID `json:"recipient_project_id,omitempty"`
}
// Share is the row shape returned from list / grant calls.
type Share struct {
ID uuid.UUID `db:"id" json:"id"`
ChecklistID uuid.UUID `db:"checklist_id" json:"checklist_id"`
RecipientKind string `db:"recipient_kind" json:"recipient_kind"`
RecipientUserID *uuid.UUID `db:"recipient_user_id" json:"recipient_user_id,omitempty"`
RecipientOffice *string `db:"recipient_office" json:"recipient_office,omitempty"`
RecipientPartnerUnitID *uuid.UUID `db:"recipient_partner_unit_id" json:"recipient_partner_unit_id,omitempty"`
RecipientProjectID *uuid.UUID `db:"recipient_project_id" json:"recipient_project_id,omitempty"`
GrantedBy uuid.UUID `db:"granted_by" json:"granted_by"`
GrantedAt time.Time `db:"granted_at" json:"granted_at"`
// Display-name enrichment for the recipient — owners want to see
// "Sarah Schmidt" not just a UUID on the grants list.
RecipientLabel string `db:"recipient_label" json:"recipient_label"`
}
// Grant creates a new share row. Caller must own the parent checklist
// (or be global_admin). Recipient validity (FK targets exist + kind
// matches the populated recipient_* column) enforced before INSERT.
func (s *ChecklistShareService) Grant(ctx context.Context, callerID uuid.UUID, slug string, input ShareGrantInput) (*Share, error) {
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return nil, err
}
// Ownership check — Grant is owner-only (global_admin can demote
// global templates but doesn't author shares).
if row.OwnerID != callerID {
return nil, fmt.Errorf("%w: only the owner can grant shares", ErrForbidden)
}
kind := strings.ToLower(strings.TrimSpace(input.RecipientKind))
if err := validateShareInput(kind, input); err != nil {
return nil, err
}
id := uuid.New()
now := time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin grant tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.checklist_shares
(id, checklist_id, recipient_kind, recipient_user_id, recipient_office,
recipient_partner_unit_id, recipient_project_id, granted_by, granted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, row.ID, kind,
input.UserID, nullableString(input.Office), input.PartnerUnitID, input.ProjectID,
callerID, now,
); err != nil {
// Map the partial-unique-index conflict into a friendly 409.
if pqUniqueViolation(err) {
return nil, fmt.Errorf("%w: this recipient already has a grant on this checklist", ErrInvalidInput)
}
return nil, fmt.Errorf("insert checklist_share: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
meta := map[string]any{
"checklist_id": row.ID,
"slug": slug,
"share_id": id,
"recipient_kind": kind,
}
switch kind {
case "user":
meta["recipient_user_id"] = input.UserID
case "office":
meta["recipient_office"] = input.Office
case "partner_unit":
meta["recipient_partner_unit_id"] = input.PartnerUnitID
case "project":
meta["recipient_project_id"] = input.ProjectID
}
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.shared",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: meta,
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit grant: %w", err)
}
return s.getShareByID(ctx, callerID, id)
}
// Revoke deletes a share row. Owner of the parent checklist OR
// global_admin. Audited as 'checklist.unshared' with the recipient meta
// captured pre-delete.
func (s *ChecklistShareService) Revoke(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) error {
share, err := s.getShareByID(ctx, callerID, shareID)
if err != nil {
return err
}
// Resolve owner of the parent checklist for the authorization gate.
// templates.GetBySlug needs a slug we don't have; inline a minimal
// owner lookup keyed on the share's checklist_id.
var ownerID uuid.UUID
if err := s.db.GetContext(ctx, &ownerID,
`SELECT owner_id FROM paliad.checklists WHERE id = $1`, share.ChecklistID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotVisible
}
return fmt.Errorf("fetch checklist owner: %w", err)
}
if ownerID != callerID {
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return err
}
if user == nil || user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only the owner or a global_admin can revoke a share", ErrForbidden)
}
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin revoke tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.checklist_shares WHERE id = $1`, shareID); err != nil {
return fmt.Errorf("delete checklist_share: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
meta := map[string]any{
"checklist_id": share.ChecklistID,
"share_id": share.ID,
"recipient_kind": share.RecipientKind,
}
switch share.RecipientKind {
case "user":
meta["recipient_user_id"] = share.RecipientUserID
case "office":
meta["recipient_office"] = share.RecipientOffice
case "partner_unit":
meta["recipient_partner_unit_id"] = share.RecipientPartnerUnitID
case "project":
meta["recipient_project_id"] = share.RecipientProjectID
}
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.unshared",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: meta,
}); err != nil {
return err
}
return tx.Commit()
}
// ListGrants returns every share row for the checklist. Owner-only —
// recipients only learn about shares affecting them implicitly via the
// visibility predicate.
func (s *ChecklistShareService) ListGrants(ctx context.Context, callerID uuid.UUID, slug string) ([]Share, error) {
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return nil, err
}
if row.OwnerID != callerID {
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return nil, err
}
if user == nil || user.GlobalRole != "global_admin" {
return nil, fmt.Errorf("%w: only the owner or a global_admin can list shares", ErrForbidden)
}
}
rows := []Share{}
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
s.granted_by, s.granted_at,
COALESCE(
CASE s.recipient_kind
WHEN 'user' THEN ru.display_name
WHEN 'office' THEN s.recipient_office
WHEN 'partner_unit' THEN pu.name
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
END,
''
) AS recipient_label
FROM paliad.checklist_shares s
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
WHERE s.checklist_id = $1
ORDER BY s.granted_at DESC`
if err := s.db.SelectContext(ctx, &rows, q, row.ID); err != nil {
return nil, fmt.Errorf("list checklist_shares: %w", err)
}
return rows, nil
}
// --- internals ------------------------------------------------------------
func (s *ChecklistShareService) getShareByID(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) (*Share, error) {
var row Share
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
s.granted_by, s.granted_at,
COALESCE(
CASE s.recipient_kind
WHEN 'user' THEN ru.display_name
WHEN 'office' THEN s.recipient_office
WHEN 'partner_unit' THEN pu.name
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
END,
''
) AS recipient_label
FROM paliad.checklist_shares s
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
WHERE s.id = $1`
err := s.db.GetContext(ctx, &row, q, shareID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch checklist_share: %w", err)
}
return &row, nil
}
func (s *ChecklistShareService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
u, err := s.users.GetByID(ctx, userID)
if err != nil || u == nil {
return "", err
}
return u.Email, nil
}
// --- pure helpers ---------------------------------------------------------
func validateShareInput(kind string, input ShareGrantInput) error {
switch kind {
case "user":
if input.UserID == nil {
return fmt.Errorf("%w: recipient_user_id required when recipient_kind=user", ErrInvalidInput)
}
case "office":
off := strings.TrimSpace(input.Office)
if off == "" {
return fmt.Errorf("%w: recipient_office required when recipient_kind=office", ErrInvalidInput)
}
if !offices.IsValid(off) {
return fmt.Errorf("%w: unknown office %q", ErrInvalidInput, off)
}
case "partner_unit":
if input.PartnerUnitID == nil {
return fmt.Errorf("%w: recipient_partner_unit_id required when recipient_kind=partner_unit", ErrInvalidInput)
}
case "project":
if input.ProjectID == nil {
return fmt.Errorf("%w: recipient_project_id required when recipient_kind=project", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: recipient_kind must be user|office|partner_unit|project, got %q", ErrInvalidInput, kind)
}
return nil
}
func nullableString(s string) any {
t := strings.TrimSpace(s)
if t == "" {
return nil
}
return t
}
// pqUniqueViolation reports whether the error is a Postgres
// unique_violation (SQLSTATE 23505). lib/pq exposes it via the .Code()
// method; sqlx surfaces it untouched. We sniff via the error string to
// avoid pulling in lib/pq's Error type here.
func pqUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key")
}

View File

@@ -0,0 +1,107 @@
package services
import (
"errors"
"strings"
"testing"
"github.com/google/uuid"
)
func TestValidateShareInput(t *testing.T) {
uid := uuid.New()
puID := uuid.New()
prID := uuid.New()
cases := []struct {
name string
kind string
input ShareGrantInput
wantErr bool
}{
{"user happy", "user", ShareGrantInput{RecipientKind: "user", UserID: &uid}, false},
{"user missing id", "user", ShareGrantInput{RecipientKind: "user"}, true},
{"office happy", "office", ShareGrantInput{RecipientKind: "office", Office: "munich"}, false},
{"office unknown key", "office", ShareGrantInput{RecipientKind: "office", Office: "atlantis"}, true},
{"office empty", "office", ShareGrantInput{RecipientKind: "office"}, true},
{"partner_unit happy", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit", PartnerUnitID: &puID}, false},
{"partner_unit missing id", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit"}, true},
{"project happy", "project", ShareGrantInput{RecipientKind: "project", ProjectID: &prID}, false},
{"project missing id", "project", ShareGrantInput{RecipientKind: "project"}, true},
{"unknown kind", "bogus", ShareGrantInput{RecipientKind: "bogus"}, true},
}
for _, c := range cases {
err := validateShareInput(c.kind, c.input)
if c.wantErr && !errors.Is(err, ErrInvalidInput) {
t.Errorf("%s: expected ErrInvalidInput, got %v", c.name, err)
}
if !c.wantErr && err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
}
}
func TestPredicateIncludesAllShareBranches(t *testing.T) {
pred := checklistVisibilityPredicate("c", 1)
wants := []string{
"c.owner_id = $1",
"c.visibility IN ('firm', 'global')",
"u.global_role = 'global_admin'",
"s.recipient_kind = 'user'",
"s.recipient_kind = 'office'",
"s.recipient_kind = 'partner_unit'",
"s.recipient_kind = 'project'",
"paliad.checklist_shares",
"paliad.partner_unit_members",
"paliad.projects",
"paliad.project_teams",
}
for _, w := range wants {
if !strings.Contains(pred, w) {
t.Errorf("predicate missing %q in:\n%s", w, pred)
}
}
}
func TestPqUniqueViolationDetection(t *testing.T) {
cases := []struct {
err string
want bool
}{
{"pq: duplicate key value violates unique constraint \"checklist_shares_user_uniq\"", true},
{"pq: 23505 something", true},
{"some other error", false},
}
for _, c := range cases {
got := pqUniqueViolation(errors.New(c.err))
if got != c.want {
t.Errorf("pqUniqueViolation(%q) = %v; want %v", c.err, got, c.want)
}
}
if pqUniqueViolation(nil) {
t.Error("nil err should not be a unique violation")
}
}
func TestNullableString(t *testing.T) {
if got := nullableString(""); got != nil {
t.Errorf("empty should map to nil, got %v", got)
}
if got := nullableString(" "); got != nil {
t.Errorf("whitespace should map to nil, got %v", got)
}
if got := nullableString(" munich "); got != "munich" {
t.Errorf("expected trimmed 'munich', got %v", got)
}
}
func TestNormaliseSliceAVisibilityAcceptsShared(t *testing.T) {
for _, v := range []string{"private", "firm", "shared"} {
if _, err := normaliseSliceAVisibility(v); err != nil {
t.Errorf("Slice-B visibility %q rejected: %v", v, err)
}
}
if _, err := normaliseSliceAVisibility("global"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("'global' should be rejected as author-set, got %v", err)
}
}

View File

@@ -0,0 +1,586 @@
package services
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
// ChecklistTemplateService is the write surface for user-authored checklist
// templates (paliad.checklists, mig 114). Create / Update / Delete on
// owner-only paths; SetVisibility on private↔firm only (Slice A — Slice B
// adds 'shared' grants, Slice C adds 'global' via admin promotion).
type ChecklistTemplateService struct {
db *sqlx.DB
catalog *ChecklistCatalogService
audit *SystemAuditLogService
users *UserService
}
func NewChecklistTemplateService(db *sqlx.DB, catalog *ChecklistCatalogService, audit *SystemAuditLogService, users *UserService) *ChecklistTemplateService {
return &ChecklistTemplateService{db: db, catalog: catalog, audit: audit, users: users}
}
// CreateTemplateInput is the POST body for authoring a new template.
//
// Body carries the groups[] / items[] sub-tree as JSONB; the surrounding
// metadata (title, regime, etc.) lives on dedicated columns. The
// handler validates the body shape upstream.
type CreateTemplateInput struct {
Title string `json:"title"`
Description string `json:"description"`
Regime string `json:"regime"`
Court string `json:"court"`
Reference string `json:"reference"`
Deadline string `json:"deadline"`
Lang string `json:"lang"`
Body json.RawMessage `json:"body"`
Visibility string `json:"visibility"`
}
// UpdateTemplateInput patches the owner-editable fields. Any field left
// nil is unchanged.
type UpdateTemplateInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Regime *string `json:"regime,omitempty"`
Court *string `json:"court,omitempty"`
Reference *string `json:"reference,omitempty"`
Deadline *string `json:"deadline,omitempty"`
Body *json.RawMessage `json:"body,omitempty"`
}
var (
validRegimes = map[string]bool{"UPC": true, "DE": true, "EPA": true, "OTHER": true}
validLangs = map[string]bool{"de": true, "en": true}
// Author-settable visibilities. 'shared' is implicit (set
// automatically when the first checklist_shares row exists); 'global'
// is admin-only via ChecklistPromotionService.
validVisibilities = map[string]bool{"private": true, "firm": true, "shared": true}
titleMaxLen = 200
descriptionMaxLen = 2000
freeTextMaxLen = 200
slugSafeChars = regexp.MustCompile(`[^a-z0-9-]+`)
)
// Create inserts a new authored template owned by userID. Returns the
// created row; emits a `checklist.authored` audit event.
func (s *ChecklistTemplateService) Create(ctx context.Context, userID uuid.UUID, input CreateTemplateInput) (*models.Checklist, error) {
title, err := requireNonEmptyTrimmed(input.Title, "title", titleMaxLen)
if err != nil {
return nil, err
}
regime, err := normaliseRegime(input.Regime)
if err != nil {
return nil, err
}
lang, err := normaliseLang(input.Lang)
if err != nil {
return nil, err
}
visibility, err := normaliseSliceAVisibility(input.Visibility)
if err != nil {
return nil, err
}
if err := validateBodyShape(input.Body); err != nil {
return nil, err
}
slug, err := s.generateSlug(ctx, title)
if err != nil {
return nil, err
}
now := time.Now().UTC()
id := uuid.New()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin create tx: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO paliad.checklists
(id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $13)`,
id, slug, userID, title,
clampFreeText(input.Description, descriptionMaxLen),
regime,
clampFreeText(input.Court, freeTextMaxLen),
clampFreeText(input.Reference, freeTextMaxLen),
clampFreeText(input.Deadline, freeTextMaxLen),
lang,
string(input.Body),
visibility,
now,
)
if err != nil {
return nil, fmt.Errorf("insert checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.authored",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": id,
"slug": slug,
"visibility": visibility,
},
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create checklist: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// Update mutates an authored template. Owner-only; non-owner attempts
// return ErrForbidden. Emits `checklist.edited` with the names of the
// changed fields in metadata.changed_fields[].
func (s *ChecklistTemplateService) Update(ctx context.Context, userID uuid.UUID, slug string, input UpdateTemplateInput) (*models.Checklist, error) {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
changed := []string{}
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Title != nil {
t, err := requireNonEmptyTrimmed(*input.Title, "title", titleMaxLen)
if err != nil {
return nil, err
}
appendSet("title", t)
changed = append(changed, "title")
}
if input.Description != nil {
appendSet("description", clampFreeText(*input.Description, descriptionMaxLen))
changed = append(changed, "description")
}
if input.Regime != nil {
r, err := normaliseRegime(*input.Regime)
if err != nil {
return nil, err
}
appendSet("regime", r)
changed = append(changed, "regime")
}
if input.Court != nil {
appendSet("court", clampFreeText(*input.Court, freeTextMaxLen))
changed = append(changed, "court")
}
if input.Reference != nil {
appendSet("reference", clampFreeText(*input.Reference, freeTextMaxLen))
changed = append(changed, "reference")
}
if input.Deadline != nil {
appendSet("deadline", clampFreeText(*input.Deadline, freeTextMaxLen))
changed = append(changed, "deadline")
}
if input.Body != nil {
if err := validateBodyShape(*input.Body); err != nil {
return nil, err
}
sets = append(sets, fmt.Sprintf("body = $%d::jsonb", next))
args = append(args, string(*input.Body))
next++
changed = append(changed, "body")
}
if len(sets) == 0 {
return row, nil
}
// Version bump (Slice C). Title and body are the meaningful edits
// that warrant a "your snapshot is outdated" badge on existing
// instances. Pure metadata tweaks (description / court / reference
// / deadline) update updated_at but don't bump version — we don't
// want every typo correction to nag users with an outdated badge.
versionBumped := false
for _, f := range changed {
if f == "title" || f == "body" {
versionBumped = true
break
}
}
if versionBumped {
sets = append(sets, "version = version + 1")
}
appendSet("updated_at", time.Now().UTC())
args = append(args, row.ID)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin update tx: %w", err)
}
defer tx.Rollback()
q := fmt.Sprintf(`UPDATE paliad.checklists SET %s WHERE id = $%d`,
strings.Join(sets, ", "), next)
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.edited",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"changed_fields": changed,
},
}); err != nil {
return nil, err
}
// Slice C — emit a separate 'checklist.versioned' event when the
// edit actually bumped the version. Dashboards / future popularity
// sort can read this without parsing changed_fields[].
if versionBumped {
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.versioned",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"prior_version": row.Version,
"new_version": row.Version + 1,
},
}); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update checklist: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// SetVisibility flips the visibility level. Slice A allows only the
// private ↔ firm transitions; Slice B opens 'shared' (requires share
// grants); Slice C opens 'global' via the admin promotion service.
func (s *ChecklistTemplateService) SetVisibility(ctx context.Context, userID uuid.UUID, slug string, visibility string) (*models.Checklist, error) {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return nil, err
}
target, err := normaliseSliceAVisibility(visibility)
if err != nil {
return nil, err
}
if row.Visibility == target {
return row, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin visibility tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklists
SET visibility = $2, updated_at = now()
WHERE id = $1`, row.ID, target); err != nil {
return nil, fmt.Errorf("update visibility: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.visibility_changed",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"from": row.Visibility,
"to": target,
},
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit visibility: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// Delete removes the authored template. Existing instances survive via
// template_snapshot; new instance creation against this slug fails.
func (s *ChecklistTemplateService) Delete(ctx context.Context, userID uuid.UUID, slug string) error {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin delete tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.checklists WHERE id = $1`, row.ID); err != nil {
return fmt.Errorf("delete checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.deleted",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"was_visibility": row.Visibility,
},
}); err != nil {
return err
}
return tx.Commit()
}
// ListOwnedBy returns every authored template owned by the caller. Used
// by the 'Meine Vorlagen' tab on /checklists.
func (s *ChecklistTemplateService) ListOwnedBy(ctx context.Context, userID uuid.UUID) ([]models.Checklist, error) {
rows := []models.Checklist{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, promoted_at, promoted_by,
version, created_at, updated_at
FROM paliad.checklists
WHERE owner_id = $1
ORDER BY updated_at DESC`, userID); err != nil {
return nil, fmt.Errorf("list owned checklists: %w", err)
}
return rows, nil
}
// GetBySlug returns one authored template by slug; applies visibility.
func (s *ChecklistTemplateService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
var row models.Checklist
q := `SELECT id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, promoted_at, promoted_by,
version, created_at, updated_at
FROM paliad.checklists
WHERE slug = $2
AND ` + checklistVisibilityPredicate("paliad.checklists", 1)
err := s.db.GetContext(ctx, &row, q, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch checklist: %w", err)
}
return &row, nil
}
// --- internals ------------------------------------------------------------
// requireOwnerOrAdmin fetches the row and returns it iff caller is owner
// or global_admin. Other callers get ErrForbidden (template visible to
// many users, only some can mutate).
func (s *ChecklistTemplateService) requireOwnerOrAdmin(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
row, err := s.GetBySlug(ctx, userID, slug)
if err != nil {
return nil, err
}
if row.OwnerID == userID {
return row, nil
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user != nil && user.GlobalRole == "global_admin" {
return row, nil
}
return nil, fmt.Errorf("%w: only the owner or a global_admin can modify this checklist", ErrForbidden)
}
func (s *ChecklistTemplateService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
u, err := s.users.GetByID(ctx, userID)
if err != nil || u == nil {
return "", err
}
return u.Email, nil
}
// generateSlug builds a 'u-<title-slug>-<6hex>' slug. Three retries on
// collision (against authored table + static catalog). After three
// failures we fall back to a pure-random suffix so the create path
// never wedges.
func (s *ChecklistTemplateService) generateSlug(ctx context.Context, title string) (string, error) {
base := slugifyTitle(title)
if base == "" {
base = "checklist"
}
for attempt := 0; attempt < 3; attempt++ {
suffix, err := randomHex(3)
if err != nil {
return "", err
}
slug := "u-" + base + "-" + suffix
if len(slug) > 64 {
slug = slug[:64]
}
taken, err := s.slugTaken(ctx, slug)
if err != nil {
return "", err
}
if !taken {
return slug, nil
}
}
suffix, err := randomHex(6)
if err != nil {
return "", err
}
return "u-" + suffix, nil
}
func (s *ChecklistTemplateService) slugTaken(ctx context.Context, slug string) (bool, error) {
if s.catalog.IsStaticSlug(slug) {
return true, nil
}
var n int
if err := s.db.GetContext(ctx, &n,
`SELECT count(*) FROM paliad.checklists WHERE slug = $1`, slug); err != nil {
return false, fmt.Errorf("slug taken check: %w", err)
}
return n > 0, nil
}
// --- pure helpers ---------------------------------------------------------
func slugifyTitle(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
s = strings.ReplaceAll(s, "ä", "ae")
s = strings.ReplaceAll(s, "ö", "oe")
s = strings.ReplaceAll(s, "ü", "ue")
s = strings.ReplaceAll(s, "ß", "ss")
s = slugSafeChars.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 40 {
s = s[:40]
}
return strings.Trim(s, "-")
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
return hex.EncodeToString(b), nil
}
func requireNonEmptyTrimmed(v, field string, max int) (string, error) {
t := strings.TrimSpace(v)
if t == "" {
return "", fmt.Errorf("%w: %s is required", ErrInvalidInput, field)
}
if len(t) > max {
return "", fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidInput, field, max)
}
return t, nil
}
func clampFreeText(v string, max int) string {
v = strings.TrimSpace(v)
if len(v) > max {
v = v[:max]
}
return v
}
func normaliseRegime(v string) (string, error) {
r := strings.ToUpper(strings.TrimSpace(v))
if r == "" {
r = "OTHER"
}
if !validRegimes[r] {
return "", fmt.Errorf("%w: regime must be UPC | DE | EPA | OTHER, got %q", ErrInvalidInput, v)
}
return r, nil
}
func normaliseLang(v string) (string, error) {
l := strings.ToLower(strings.TrimSpace(v))
if l == "" {
l = "de"
}
if !validLangs[l] {
return "", fmt.Errorf("%w: lang must be de | en, got %q", ErrInvalidInput, v)
}
return l, nil
}
func normaliseSliceAVisibility(v string) (string, error) {
x := strings.ToLower(strings.TrimSpace(v))
if x == "" {
x = "private"
}
if !validVisibilities[x] {
return "", fmt.Errorf("%w: visibility must be private | firm | shared, got %q (global is admin-only)", ErrInvalidInput, v)
}
return x, nil
}
// validateBodyShape enforces { "groups": [...] } with at least one
// non-empty group and at least one non-empty item somewhere. Authored
// templates aren't useful without content.
func validateBodyShape(body json.RawMessage) error {
if len(body) == 0 {
return fmt.Errorf("%w: body is required", ErrInvalidInput)
}
var shape struct {
Groups []checklists.Group `json:"groups"`
}
if err := json.Unmarshal(body, &shape); err != nil {
return fmt.Errorf("%w: body must be {\"groups\":[...]} (%v)", ErrInvalidInput, err)
}
if len(shape.Groups) == 0 {
return fmt.Errorf("%w: body must contain at least one group", ErrInvalidInput)
}
totalItems := 0
for _, g := range shape.Groups {
totalItems += len(g.Items)
}
if totalItems == 0 {
return fmt.Errorf("%w: body must contain at least one item", ErrInvalidInput)
}
return nil
}

View File

@@ -0,0 +1,129 @@
package services
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestSlugifyTitle(t *testing.T) {
cases := []struct{ in, want string }{
{"UPC Klageschrift Strategie", "upc-klageschrift-strategie"},
{"Hülle für Münch (München!)", "huelle-fuer-muench-muenchen"},
{" ", ""},
{"&&&", ""},
{"A really really really really long title that ought to be clamped to forty chars max", "a-really-really-really-really-long-title"},
{"Straße ABC", "strasse-abc"},
{"---leading-and-trailing---", "leading-and-trailing"},
}
for _, c := range cases {
got := slugifyTitle(c.in)
if got != c.want {
t.Errorf("slugifyTitle(%q) = %q; want %q", c.in, got, c.want)
}
}
}
func TestNormaliseRegime(t *testing.T) {
for _, valid := range []string{"upc", "DE", " epa ", "Other", ""} {
if _, err := normaliseRegime(valid); err != nil {
t.Errorf("normaliseRegime(%q) errored unexpectedly: %v", valid, err)
}
}
if _, err := normaliseRegime("bogus"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("normaliseRegime(bogus) expected ErrInvalidInput, got %v", err)
}
}
func TestNormaliseLang(t *testing.T) {
for _, valid := range []string{"de", "EN", " ", ""} {
if _, err := normaliseLang(valid); err != nil {
t.Errorf("normaliseLang(%q) errored: %v", valid, err)
}
}
if _, err := normaliseLang("fr"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("normaliseLang(fr) expected ErrInvalidInput, got %v", err)
}
}
func TestNormaliseSliceAVisibility(t *testing.T) {
// Slice B opened up 'shared' as a valid author-set visibility
// (alongside 'private' and 'firm'). 'global' stays admin-only via
// ChecklistPromotionService.
for _, valid := range []string{"private", "firm", "shared", " ", ""} {
if _, err := normaliseSliceAVisibility(valid); err != nil {
t.Errorf("visibility(%q) errored: %v", valid, err)
}
}
for _, bad := range []string{"global", "public"} {
if _, err := normaliseSliceAVisibility(bad); !errors.Is(err, ErrInvalidInput) {
t.Errorf("visibility(%q) expected ErrInvalidInput, got %v", bad, err)
}
}
}
func TestRequireNonEmptyTrimmed(t *testing.T) {
if _, err := requireNonEmptyTrimmed(" ", "title", 200); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty title should be rejected, got %v", err)
}
if got, err := requireNonEmptyTrimmed(" hello ", "title", 200); err != nil || got != "hello" {
t.Errorf("expected 'hello', got %q (err=%v)", got, err)
}
if _, err := requireNonEmptyTrimmed(strings.Repeat("x", 201), "title", 200); !errors.Is(err, ErrInvalidInput) {
t.Errorf("over-length title should be rejected, got %v", err)
}
}
func TestValidateBodyShape(t *testing.T) {
// Happy path: at least one group, at least one item.
ok := json.RawMessage(`{"groups":[{"titleDE":"G1","titleEN":"G1","items":[{"labelDE":"X","labelEN":"X"}]}]}`)
if err := validateBodyShape(ok); err != nil {
t.Errorf("valid body rejected: %v", err)
}
// Empty groups.
if err := validateBodyShape(json.RawMessage(`{"groups":[]}`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty groups expected ErrInvalidInput, got %v", err)
}
// Group with no items.
if err := validateBodyShape(json.RawMessage(`{"groups":[{"titleDE":"G","titleEN":"G","items":[]}]}`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty items expected ErrInvalidInput, got %v", err)
}
// Missing field.
if err := validateBodyShape(json.RawMessage(nil)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("nil body expected ErrInvalidInput, got %v", err)
}
// Malformed JSON.
if err := validateBodyShape(json.RawMessage(`{not json`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("malformed body expected ErrInvalidInput, got %v", err)
}
}
func TestChecklistCatalogIsStaticSlug(t *testing.T) {
// nil DB is fine — we never touch it in this test.
cat := NewChecklistCatalogService(nil)
if !cat.IsStaticSlug("upc-statement-of-claim") {
t.Error("expected static slug to be detected")
}
if cat.IsStaticSlug("u-some-authored-slug") {
t.Error("unexpected static-slug match for authored slug")
}
}
func TestChecklistVisibilityPredicate(t *testing.T) {
got := checklistVisibilityPredicate("c", 1)
for _, want := range []string{"c.owner_id = $1", "c.visibility IN ('firm', 'global')", "u.global_role = 'global_admin'"} {
if !strings.Contains(got, want) {
t.Errorf("predicate missing %q in: %s", want, got)
}
}
}
func TestClampFreeText(t *testing.T) {
if got := clampFreeText(" hello ", 200); got != "hello" {
t.Errorf("expected trimmed 'hello', got %q", got)
}
if got := clampFreeText(strings.Repeat("x", 250), 200); len(got) != 200 {
t.Errorf("expected clamp to 200, got len=%d", len(got))
}
}

View File

@@ -12,6 +12,8 @@ func EmailTemplateSampleData(key, lang, slot string) map[string]any {
switch key {
case EmailTemplateKeyInvitation:
return invitationSample(lang)
case EmailTemplateKeyAddUserWelcome:
return addUserWelcomeSample(lang)
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestSample(lang, slot)
case EmailTemplateKeyBase:
@@ -98,6 +100,30 @@ func deadlineDigestSample(lang, slot string) map[string]any {
}
}
// t-paliad-223 Slice B (#49) — sample data for the Add-User welcome mail.
// The variable contract mirrors what UserService.AdminCreateUserFull
// passes to MailService.SendTemplate at runtime.
func addUserWelcomeSample(lang string) map[string]any {
if lang == "en" {
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "new.colleague@hlc.com",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
"BaseURL": "https://paliad.de",
"Firm": "HLC",
}
}
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
"BaseURL": "https://paliad.de",
"Firm": "HLC",
}
}
func baseSample(lang string) map[string]any {
subj := "Beispielbetreff"
if lang == "en" {

View File

@@ -41,11 +41,17 @@ const (
EmailTemplateKeyInvitation = "invitation"
EmailTemplateKeyDeadlineDigest = "deadline_digest"
EmailTemplateKeyBase = "base"
// EmailTemplateKeyAddUserWelcome — t-paliad-223 Slice B (#49). Sent when
// a global_admin directly creates a paliad.users + auth.users pair from
// /admin/team's "Konto direkt anlegen" form. Carries a Supabase
// recovery-link so the new colleague can set their own password.
EmailTemplateKeyAddUserWelcome = "add_user_welcome"
)
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
var CanonicalEmailTemplateKeys = []string{
EmailTemplateKeyInvitation,
EmailTemplateKeyAddUserWelcome,
EmailTemplateKeyDeadlineDigest,
EmailTemplateKeyBase,
}
@@ -420,6 +426,10 @@ var defaultSubjects = map[string]map[string]string{
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
},
EmailTemplateKeyAddUserWelcome: {
"de": `[Paliad] Ihr Paliad-Konto ist bereit`,
"en": `[Paliad] Your Paliad account is ready`,
},
EmailTemplateKeyDeadlineDigest: {
"de": digestSubjectDE,
"en": digestSubjectEN,

View File

@@ -21,6 +21,8 @@ func EmailTemplateVariables(key string) []EmailTemplateVariable {
switch key {
case EmailTemplateKeyInvitation:
return invitationVariables
case EmailTemplateKeyAddUserWelcome:
return addUserWelcomeVariables
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestVariables
case EmailTemplateKeyBase:
@@ -51,6 +53,30 @@ var invitationVariables = []EmailTemplateVariable{
SampleDE: "HLC", SampleEN: "HLC"},
}
// t-paliad-223 Slice B (#49) — variables consumed by the Add-User welcome
// mail. UserService.AdminCreateUserFull populates these at send time.
var addUserWelcomeVariables = []EmailTemplateVariable{
{Name: ".InviterName", Type: "string",
Description: "Anzeigename der/des global_admin, die das Konto angelegt hat.",
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
{Name: ".InviterEmail", Type: "string",
Description: "E-Mail-Adresse der/des global_admin.",
SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
{Name: ".ToEmail", Type: "string",
Description: "Empfänger:in (E-Mail der neuen Person).",
SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
{Name: ".MagicLink", Type: "string",
Description: "Einmaliger Supabase-Recovery-Link zum Passwort-Setzen.",
SampleDE: "https://supabase.paliad.de/auth/v1/verify?token=…",
SampleEN: "https://supabase.paliad.de/auth/v1/verify?token=…"},
{Name: ".BaseURL", Type: "string",
Description: "Öffentliche Paliad-URL (PALIAD_BASE_URL).",
SampleDE: "https://paliad.de", SampleEN: "https://paliad.de"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME).",
SampleDE: "HLC", SampleEN: "HLC"},
}
var deadlineDigestVariables = []EmailTemplateVariable{
{Name: ".Slot", Type: "string",
Description: "Trigger-Slot: \"morning\" oder \"evening\". Body verwendet typischerweise .IsEvening.",

View File

@@ -173,6 +173,53 @@ func TestRenderTemplateInvitation(t *testing.T) {
}
}
// TestRenderTemplateAddUserWelcome — t-paliad-223 Slice B (#49). Catches
// a typo in either add_user_welcome.{de,en}.html: the rendered body must
// contain the inviter, the magic-link, the firm name, and the localised
// fallback subject from defaultSubjects must look right.
func TestRenderTemplateAddUserWelcome(t *testing.T) {
svc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
for _, lang := range []string{"de", "en"} {
t.Run(lang, func(t *testing.T) {
subject, html, err := svc.RenderTemplate(TemplateData{
Lang: lang,
Name: EmailTemplateKeyAddUserWelcome,
Data: map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
"BaseURL": "https://paliad.de",
},
})
if err != nil {
t.Fatalf("RenderTemplate: %v", err)
}
for _, want := range []string{
"Maria Schmidt", "neu.kollege@hlc.de",
"https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
"https://paliad.de/login",
// {{.Firm}} placeholder must render — branding default is "HLC".
"HLC",
} {
if !strings.Contains(html, want) {
t.Errorf("[%s] rendered html missing %q", lang, want)
}
}
wantSubject := "[Paliad] Ihr Paliad-Konto ist bereit"
if lang == "en" {
wantSubject = "[Paliad] Your Paliad account is ready"
}
if subject != wantSubject {
t.Errorf("[%s] subject got %q, want %q", lang, subject, wantSubject)
}
})
}
}
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure
// carries both the text and HTML parts — an earlier refactor dropped one
// part by mistake, caught by this.

View File

@@ -0,0 +1,242 @@
// Package services — SupabaseAdminService — thin HTTP client for the
// privileged Supabase Admin API endpoints.
//
// t-paliad-223 Slice B (#49) — the new "Add User" path on /admin/team needs
// to create an auth.users row before inserting paliad.users (paliad.users.id
// is FK-constrained to auth.users.id). The Supabase JS / Go client library
// would be overkill for the three calls we actually make; this file is
// ~150 LoC of plain net/http instead.
//
// Only three Admin-API calls are exercised here:
//
// - POST {SUPABASE_URL}/auth/v1/admin/users
// Create an auth.users row with email_confirm=true so the user can log
// in via a recovery link without going through the email-confirm step.
//
// - POST {SUPABASE_URL}/auth/v1/admin/generate_link
// Mint a recovery link for the new user; paliad emails it via the
// existing MailService template (NOT Supabase's default mail) so the
// welcome message stays paliad-branded.
//
// - DELETE {SUPABASE_URL}/auth/v1/admin/users/{id}
// Best-effort rollback when the paliad.users insert fails after the
// auth.users row has been created. Failure here just leaves an
// unonboarded auth.users row that "Onboard existing" can recover.
//
// All requests carry the service-role key in BOTH the `apikey` header AND
// the `Authorization: Bearer` header — Supabase's PostgREST gateway checks
// the former, the auth admin handlers check the latter.
//
// SECURITY: SUPABASE_SERVICE_ROLE_KEY is one of the most-privileged
// credentials in the deploy. It must NEVER be sent to the browser or
// logged. Storage is Dokploy secret, age-encrypted at rest.
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/google/uuid"
)
// Sentinel errors. Handlers map these to HTTP status codes.
var (
// ErrSupabaseAdminUnavailable signals SUPABASE_SERVICE_ROLE_KEY is unset.
// Handlers map to 503 — the Add-User path is the only feature that
// requires it; everything else keeps working.
ErrSupabaseAdminUnavailable = errors.New("supabase admin api unavailable (SUPABASE_SERVICE_ROLE_KEY not set)")
// ErrSupabaseEmailExists is returned by CreateAuthUser when the email
// already exists in auth.users. Handlers map to 409 with a nudge to
// use "Onboard existing".
ErrSupabaseEmailExists = errors.New("auth.users row already exists for this email")
)
// SupabaseAdminClient is the thin HTTP client. Constructed once at server
// boot; the embedded *http.Client is reused for connection pooling.
//
// Enabled() reports whether SUPABASE_SERVICE_ROLE_KEY is configured. When
// it isn't, every call returns ErrSupabaseAdminUnavailable so the rest of
// the boot path stays runnable for deployments that don't need Add-User.
type SupabaseAdminClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
// NewSupabaseAdminClient wires the client. supabaseURL is required (already
// validated at boot for the anon-key flow); serviceRoleKey may be empty.
//
// Timeout is 10s — Supabase Admin API calls are normally sub-second; 10s
// is forgiving enough for cold starts on a slow network but short enough
// that a hung call doesn't block the admin UI indefinitely.
func NewSupabaseAdminClient(supabaseURL, serviceRoleKey string) *SupabaseAdminClient {
return &SupabaseAdminClient{
baseURL: strings.TrimRight(supabaseURL, "/"),
apiKey: strings.TrimSpace(serviceRoleKey),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// Enabled reports whether the client has a service-role key to use.
func (c *SupabaseAdminClient) Enabled() bool {
return c != nil && c.apiKey != ""
}
// CreateAuthUser creates an auth.users row with email_confirm=true and no
// password (the new user signs in via the recovery link emailed later).
// Returns the new auth.users.id.
//
// 422 from Supabase typically means "email already exists" — mapped to
// ErrSupabaseEmailExists so the handler nudges the admin to "Onboard
// existing" instead.
func (c *SupabaseAdminClient) CreateAuthUser(ctx context.Context, email string) (uuid.UUID, error) {
if !c.Enabled() {
return uuid.Nil, ErrSupabaseAdminUnavailable
}
body := map[string]any{
"email": strings.ToLower(strings.TrimSpace(email)),
"email_confirm": true,
}
var resp struct {
ID string `json:"id"`
Msg string `json:"msg,omitempty"`
}
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/users", body, &resp)
if err != nil {
return uuid.Nil, err
}
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
// Supabase returns 422 (or sometimes 400 with "already registered"
// in the body) when the email is taken. Lower-case-match the
// substring so we catch both casings.
if strings.Contains(strings.ToLower(string(raw)), "already") {
return uuid.Nil, ErrSupabaseEmailExists
}
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
}
if status < 200 || status >= 300 {
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
}
id, err := uuid.Parse(resp.ID)
if err != nil {
return uuid.Nil, fmt.Errorf("supabase admin create user: parse id %q: %w", resp.ID, err)
}
return id, nil
}
// GenerateRecoveryLink mints a one-time recovery link for an existing
// auth.users row. The action_link is what we email; clicking it lands the
// user on Supabase's password-reset page (which redirects to paliad.de
// after the user picks a password).
//
// The link type is "recovery" rather than "magiclink" so the user is forced
// to set a password — paliad doesn't support passwordless sign-in today.
func (c *SupabaseAdminClient) GenerateRecoveryLink(ctx context.Context, email string) (string, error) {
if !c.Enabled() {
return "", ErrSupabaseAdminUnavailable
}
body := map[string]any{
"type": "recovery",
"email": strings.ToLower(strings.TrimSpace(email)),
}
var resp struct {
ActionLink string `json:"action_link"`
Properties struct {
ActionLink string `json:"action_link"`
} `json:"properties"`
}
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/generate_link", body, &resp)
if err != nil {
return "", err
}
if status < 200 || status >= 300 {
return "", fmt.Errorf("supabase admin generate_link: status=%d body=%s", status, string(raw))
}
// Supabase has historically returned the link in both shapes (top-level
// and nested under properties). Accept either.
if resp.ActionLink != "" {
return resp.ActionLink, nil
}
if resp.Properties.ActionLink != "" {
return resp.Properties.ActionLink, nil
}
return "", fmt.Errorf("supabase admin generate_link: response missing action_link: %s", string(raw))
}
// DeleteAuthUser removes an auth.users row by id. Best-effort rollback
// after the paliad.users insert has failed. A failure here is logged but
// doesn't propagate to the caller — the row can be cleaned up later via
// "Onboard existing" or the admin UI.
func (c *SupabaseAdminClient) DeleteAuthUser(ctx context.Context, id uuid.UUID) error {
if !c.Enabled() {
return ErrSupabaseAdminUnavailable
}
status, raw, err := c.do(ctx, "DELETE", "/auth/v1/admin/users/"+id.String(), nil, nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("supabase admin delete user: status=%d body=%s", status, string(raw))
}
return nil
}
// do is the shared request helper. Returns (status, raw_body, err). When
// `out` is non-nil and the response is 2xx with a JSON body, decodes into
// it; raw_body is still returned so the caller can inspect error responses.
func (c *SupabaseAdminClient) do(ctx context.Context, method, path string, payload any, out any) (int, []byte, error) {
var rdr io.Reader
if payload != nil {
buf, err := json.Marshal(payload)
if err != nil {
return 0, nil, fmt.Errorf("marshal %s body: %w", path, err)
}
rdr = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
if err != nil {
return 0, nil, fmt.Errorf("build %s request: %w", path, err)
}
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("apikey", c.apiKey)
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("read %s response: %w", path, err)
}
if out != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, raw, fmt.Errorf("decode %s response: %w", path, err)
}
}
return resp.StatusCode, raw, nil
}
// LoadSupabaseAdminClient reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY
// from the environment and returns a client. The key is optional — when
// unset the client still wires (so dependents don't panic on nil-deref)
// but every call short-circuits with ErrSupabaseAdminUnavailable so the
// server boot stays runnable.
func LoadSupabaseAdminClient() *SupabaseAdminClient {
return NewSupabaseAdminClient(
os.Getenv("SUPABASE_URL"),
os.Getenv("SUPABASE_SERVICE_ROLE_KEY"),
)
}

View File

@@ -0,0 +1,154 @@
// Unit tests for the Supabase admin HTTP client. The client is a thin
// shim over net/http; coverage lives at the wire-shape level: header
// presence, request method, body decode, status-code → error mapping.
// No live Supabase call — every test runs against an httptest.Server.
package services
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
)
func TestSupabaseAdminClient_Disabled(t *testing.T) {
c := NewSupabaseAdminClient("https://example.invalid", "")
if c.Enabled() {
t.Fatal("Enabled() must be false when service-role key is empty")
}
ctx := context.Background()
if _, err := c.CreateAuthUser(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("CreateAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
}
if _, err := c.GenerateRecoveryLink(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("GenerateRecoveryLink must return ErrSupabaseAdminUnavailable, got %v", err)
}
if err := c.DeleteAuthUser(ctx, uuid.New()); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("DeleteAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
}
}
// TestSupabaseAdminClient_CreateAuthUser_Happy pins the wire-shape:
// POST /auth/v1/admin/users, JSON body with email_confirm=true, both
// apikey + Authorization headers present, parses the response id.
func TestSupabaseAdminClient_CreateAuthUser_Happy(t *testing.T) {
wantID := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("method = %q, want POST", r.Method)
}
if r.URL.Path != "/auth/v1/admin/users" {
t.Errorf("path = %q, want /auth/v1/admin/users", r.URL.Path)
}
if r.Header.Get("apikey") != "service-key" {
t.Errorf("missing apikey header")
}
if r.Header.Get("Authorization") != "Bearer service-key" {
t.Errorf("missing Bearer header")
}
body, _ := io.ReadAll(r.Body)
var got map[string]any
_ = json.Unmarshal(body, &got)
if got["email"] != "x@hlc.com" {
t.Errorf("email = %v, want x@hlc.com", got["email"])
}
if got["email_confirm"] != true {
t.Errorf("email_confirm = %v, want true", got["email_confirm"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"id": wantID.String()})
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
gotID, err := c.CreateAuthUser(context.Background(), " X@HLC.COM ")
if err != nil {
t.Fatalf("CreateAuthUser: %v", err)
}
if gotID != wantID {
t.Errorf("id = %s, want %s", gotID, wantID)
}
}
// TestSupabaseAdminClient_CreateAuthUser_EmailExists pins the 422-with-
// "already" body → ErrSupabaseEmailExists translation. Mapped to 409 by
// the handler.
func TestSupabaseAdminClient_CreateAuthUser_EmailExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"msg":"A user with this email address has already been registered"}`))
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
_, err := c.CreateAuthUser(context.Background(), "dup@hlc.com")
if !errors.Is(err, ErrSupabaseEmailExists) {
t.Fatalf("got %v, want ErrSupabaseEmailExists", err)
}
}
// TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes — Supabase has
// historically returned the link at top-level and nested under
// properties. Both shapes must be accepted.
func TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes(t *testing.T) {
for _, tc := range []struct {
name string
body string
want string
}{
{"top-level", `{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=A"}`, "https://supabase.paliad.de/auth/v1/verify?token=A"},
{"nested", `{"properties":{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=B"}}`, "https://supabase.paliad.de/auth/v1/verify?token=B"},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/v1/admin/generate_link" {
t.Errorf("path = %q", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"type":"recovery"`) {
t.Errorf("body missing type=recovery: %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
got, err := c.GenerateRecoveryLink(context.Background(), "x@hlc.com")
if err != nil {
t.Fatalf("GenerateRecoveryLink: %v", err)
}
if got != tc.want {
t.Errorf("link = %q, want %q", got, tc.want)
}
})
}
}
// TestSupabaseAdminClient_DeleteAuthUser pins the DELETE-by-id route shape
// + 2xx happy path; the cleanup runs after a paliad.users insert failure
// in AdminCreateUserFull, so the round-trip needs to work even with a
// short context window.
func TestSupabaseAdminClient_DeleteAuthUser(t *testing.T) {
id := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Errorf("method = %q", r.Method)
}
if r.URL.Path != "/auth/v1/admin/users/"+id.String() {
t.Errorf("path = %q", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
if err := c.DeleteAuthUser(context.Background(), id); err != nil {
t.Errorf("DeleteAuthUser: %v", err)
}
}

View File

@@ -0,0 +1,68 @@
package services
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// SystemAuditLogService is a thin write helper for paliad.system_audit_log
// (mig 102). Each domain emits its own event_type prefix
// (checklist.* / data_export* / …) so dashboards can group by feature.
//
// The audit row is best-effort INSIDE the caller's transaction — the
// caller passes its in-flight *sqlx.Tx so the audit write rolls back
// with the data change if anything else fails.
type SystemAuditLogService struct {
db *sqlx.DB
}
func NewSystemAuditLogService(db *sqlx.DB) *SystemAuditLogService {
return &SystemAuditLogService{db: db}
}
// ChecklistAuditEvent is the input shape for the WriteChecklistEvent
// helper. Scope defaults to 'org' since template-level events are firm-
// wide; instance-level events stay on paliad.project_events via the
// existing helpers.
type ChecklistAuditEvent struct {
EventType string // e.g. "checklist.authored", "checklist.edited"
ActorID uuid.UUID
ActorEmail string // captured at write time; survives user deletion
Metadata map[string]any
}
// WriteChecklistEvent inserts a row into paliad.system_audit_log with
// scope='org' and scope_root=NULL. Metadata is JSON-encoded.
func (s *SystemAuditLogService) WriteChecklistEvent(ctx context.Context, tx *sqlx.Tx, evt ChecklistAuditEvent) error {
if evt.EventType == "" {
return fmt.Errorf("system_audit_log: event_type required")
}
if evt.Metadata == nil {
evt.Metadata = map[string]any{}
}
mb, err := json.Marshal(evt.Metadata)
if err != nil {
return fmt.Errorf("system_audit_log marshal: %w", err)
}
exec := func(q string, args ...any) error {
if tx != nil {
_, err := tx.ExecContext(ctx, q, args...)
return err
}
_, err := s.db.ExecContext(ctx, q, args...)
return err
}
if err := exec(
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ($1, $2, $3, 'org', NULL, $4::jsonb)`,
evt.EventType, evt.ActorID, evt.ActorEmail, string(mb),
); err != nil {
return fmt.Errorf("system_audit_log insert: %w", err)
}
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/mail"
"strings"
"time"
@@ -56,8 +58,18 @@ var (
// UserService reads paliad.users. Writes happen via the Phase D onboarding
// endpoint and are not exposed here yet.
//
// supabase + mail + baseURL are optional dependencies wired post-construction
// via SetAddUserDeps (t-paliad-223 Slice B). They power the new "Add User"
// path on /admin/team which creates an auth.users row directly and emails
// a paliad-branded welcome message. Older paths (Create / AdminCreateUser /
// AdminUpdateUser / AdminDeleteUser) do not touch these fields and stay
// runnable when supabase admin is unwired.
type UserService struct {
db *sqlx.DB
db *sqlx.DB
supabase *SupabaseAdminClient
mail *MailService
baseURL string
}
// NewUserService wires the service to the pool.
@@ -65,6 +77,17 @@ func NewUserService(db *sqlx.DB) *UserService {
return &UserService{db: db}
}
// SetAddUserDeps injects the dependencies needed for AdminCreateUserFull
// (t-paliad-223 Slice B). Called from cmd/server/main.go once supabase
// admin + mail services + base URL are known. Safe to omit when the
// deploy doesn't need the new "Add User" path — AdminCreateUserFull will
// return ErrSupabaseAdminUnavailable in that case.
func (s *UserService) SetAddUserDeps(supabase *SupabaseAdminClient, mail *MailService, baseURL string) {
s.supabase = supabase
s.mail = mail
s.baseURL = baseURL
}
const userColumns = `id, email, display_name, office, additional_offices, practice_group,
job_title, global_role,
lang, email_preferences,
@@ -584,6 +607,193 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
return s.GetByID(ctx, authID)
}
// AdminCreateFullInput is the payload for AdminCreateUserFull (t-paliad-223
// Slice B / m/paliad#49) — the "Konto direkt anlegen" path on /admin/team.
//
// Unlike AdminCreateUser this path does NOT require a pre-existing
// auth.users row: it creates that row via the Supabase Admin API before
// inserting paliad.users in the same tx. The two-step nature means an
// auth.users row may exist with no paliad.users row if the second step
// fails — recovery is via "Onboard existing".
type AdminCreateFullInput struct {
Email string `json:"email"` // required
DisplayName string `json:"display_name"` // required
Office string `json:"office"` // required, validated against offices.IsValid
JobTitle string `json:"job_title,omitempty"`
Profession string `json:"profession,omitempty"`
Lang string `json:"lang,omitempty"`
SendWelcomeMail bool `json:"send_welcome_mail"` // default-on at the handler layer
// InviterID + InviterName + InviterEmail describe the global_admin
// performing the create. Used for the welcome-email template variables
// + the system_audit_log row. Filled by the handler from auth.uid()
// before the call, NOT from the request body, so a malicious admin
// can't impersonate another inviter.
InviterID uuid.UUID `json:"-"`
InviterName string `json:"-"`
InviterEmail string `json:"-"`
}
// AdminCreateUserFull creates both an auth.users row (via Supabase Admin
// API) AND a paliad.users row in one operation. Returns the new
// paliad.users row.
//
// Two-step flow with best-effort rollback:
// 1. Validate input (email format, allowed-domain check happens at the
// handler; office + profession + lang validated here).
// 2. POST /auth/v1/admin/users → auth_id. ErrSupabaseEmailExists if taken.
// 3. INSERT paliad.users in a tx; on failure DELETE /auth/v1/admin/users/{id}
// to roll back.
// 4. system_audit_log row written (best-effort; failure logged not raised).
// 5. If SendWelcomeMail: GenerateRecoveryLink + MailService.SendTemplate
// (best-effort; the user-create succeeds regardless).
//
// Returns ErrSupabaseAdminUnavailable when SUPABASE_SERVICE_ROLE_KEY is
// unset (handler maps to 503). Returns ErrUserAlreadyOnboarded if a
// paliad.users row exists for the same email already (defensive — should
// be unreachable given step 2 catches the auth.users dup first).
func (s *UserService) AdminCreateUserFull(ctx context.Context, input AdminCreateFullInput) (*models.User, error) {
if s.supabase == nil || !s.supabase.Enabled() {
return nil, ErrSupabaseAdminUnavailable
}
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" {
return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
}
if _, err := mail.ParseAddress(email); err != nil {
return nil, fmt.Errorf("%w: invalid email %q", ErrInvalidInput, input.Email)
}
displayName := strings.TrimSpace(input.DisplayName)
if displayName == "" {
return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
}
if !offices.IsValid(input.Office) {
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
}
jobTitle := strings.TrimSpace(input.JobTitle)
if jobTitle == "" {
jobTitle = "Associate"
}
profession := strings.TrimSpace(input.Profession)
if profession == "" {
profession = ProfessionAssociate
}
if !IsValidProfession(profession) {
return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, profession)
}
lang := strings.ToLower(strings.TrimSpace(input.Lang))
if lang == "" {
lang = "de"
}
if lang != "de" && lang != "en" {
return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang)
}
// Cheap pre-check on paliad.users — catches the rare case where
// paliad has a row but auth.users got swept (e.g. a Supabase support
// purge). The Admin-API call would still succeed and we'd hit a unique
// constraint on the FK in step 3.
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE lower(email) = $1)`, email); err != nil {
return nil, fmt.Errorf("pre-check email: %w", err)
}
if exists {
return nil, ErrUserAlreadyOnboarded
}
// Step 2 — auth.users via Supabase Admin API. ErrSupabaseEmailExists
// bubbles to the handler unchanged (409 with a "use Onboard existing"
// hint).
authID, err := s.supabase.CreateAuthUser(ctx, email)
if err != nil {
return nil, err
}
// Step 3 — paliad.users insert with rollback. The tx-rollback only
// reverts the paliad insert; the auth.users row needs an explicit
// delete because it lives in a different Postgres schema and is
// managed by Supabase's GoTrue, not our migration set.
rollbackAuth := func() {
// Detached context so a cancelled parent doesn't abort the cleanup.
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if delErr := s.supabase.DeleteAuthUser(cleanupCtx, authID); delErr != nil {
// Best-effort: log + leave a recoverable orphan rather than
// raising a new error.
slog.Warn("admin_create_full: rollback DeleteAuthUser failed", "auth_id", authID, "err", delErr)
}
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role, lang)
VALUES ($1, $2, $3, $4, $5, $6, 'standard', $7)`,
authID, email, displayName, input.Office, jobTitle, profession, lang,
); err != nil {
rollbackAuth()
return nil, fmt.Errorf("insert paliad.users: %w", err)
}
// Step 4 — audit row. Best-effort; an audit failure shouldn't break
// the user-create. Captured under a fresh context so the row is
// preserved even if the request context is on the verge of timing out.
auditCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if _, err := s.db.ExecContext(auditCtx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('user.added_by_admin', $1, $2, 'org', NULL, $3::jsonb)`,
nullableUUID(input.InviterID), input.InviterEmail,
fmt.Sprintf(`{"created_user_id":"%s","email":"%s","sent_welcome":%t}`,
authID, email, input.SendWelcomeMail),
); err != nil {
slog.Warn("admin_create_full: audit insert failed", "auth_id", authID, "err", err)
}
cancel()
// Step 5 — welcome email. Best-effort; failure logged + returned in
// the result so the admin can retry the recovery-link send separately.
if input.SendWelcomeMail {
if err := s.sendAddUserWelcome(ctx, email, lang, input); err != nil {
slog.Warn("admin_create_full: welcome mail failed", "auth_id", authID, "err", err)
// Surfaced as a non-fatal warning via the returned model's
// caller-visible side channel? For v1 we just log — the
// admin can re-send via /admin/team's "Recovery link" follow-up
// (filed as out-of-scope in design §3).
}
}
return s.GetByID(ctx, authID)
}
// sendAddUserWelcome generates the recovery link and dispatches the
// branded welcome email. Errors propagate so the caller can log them; the
// caller (AdminCreateUserFull) decides whether they're fatal.
func (s *UserService) sendAddUserWelcome(ctx context.Context, email, lang string, input AdminCreateFullInput) error {
if s.mail == nil {
return errors.New("mail service not wired")
}
link, err := s.supabase.GenerateRecoveryLink(ctx, email)
if err != nil {
return fmt.Errorf("generate recovery link: %w", err)
}
baseURL := s.baseURL
if baseURL == "" {
baseURL = "https://paliad.de"
}
return s.mail.SendTemplate(TemplateData{
To: email,
Lang: lang,
Name: EmailTemplateKeyAddUserWelcome,
Data: map[string]any{
"InviterName": input.InviterName,
"InviterEmail": input.InviterEmail,
"ToEmail": email,
"MagicLink": link,
"BaseURL": baseURL,
},
})
}
// AdminUpdateInput is the payload for AdminUpdateUser. Same shape as
// UpdateProfileInput but additionally allows the additional_offices array
// (which the self-service settings page does not expose).

View File

@@ -0,0 +1,12 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Willkommen bei Paliad</h1>
<p style="margin:0 0 12px 0;">{{.InviterName}} hat ein Konto f&uuml;r Sie bei Paliad &mdash; der Patent-Praxis-Plattform f&uuml;r {{.Firm}} &mdash; angelegt.</p>
<p style="margin:0 0 20px 0;">Bitte legen Sie ein Passwort fest, um sich zum ersten Mal anzumelden:</p>
<p style="margin:0;">
<a href="{{.MagicLink}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Passwort festlegen und anmelden
</a>
</p>
<p style="margin:20px 0 0 0;font-size:13px;color:#44403c;">Der Link ist 24 Stunden g&uuml;ltig. Anschlie&szlig;end k&ouml;nnen Sie sich jederzeit unter <a href="{{.BaseURL}}/login" style="color:#1c1917;">{{.BaseURL}}/login</a> mit Ihrer E-Mail-Adresse {{.ToEmail}} und dem neuen Passwort einloggen.</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Angelegt von {{.InviterEmail}}. Falls Sie diese Nachricht unerwartet erhalten, k&ouml;nnen Sie sie ignorieren &mdash; ohne das Festlegen eines Passworts bleibt das Konto unbenutzbar.</p>
{{end}}

View File

@@ -0,0 +1,12 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Welcome to Paliad</h1>
<p style="margin:0 0 12px 0;">{{.InviterName}} has created a Paliad account for you &mdash; Paliad is the patent practice platform for {{.Firm}}.</p>
<p style="margin:0 0 20px 0;">Please set a password to sign in for the first time:</p>
<p style="margin:0;">
<a href="{{.MagicLink}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Set password and sign in
</a>
</p>
<p style="margin:20px 0 0 0;font-size:13px;color:#44403c;">The link is valid for 24 hours. After that, you can always sign in at <a href="{{.BaseURL}}/login" style="color:#1c1917;">{{.BaseURL}}/login</a> with your email {{.ToEmail}} and the new password.</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Created by {{.InviterEmail}}. If you weren't expecting this message you can ignore it &mdash; without setting a password the account stays unusable.</p>
{{end}}