Compare commits

..

8 Commits

Author SHA1 Message Date
mAi
d723df6fd4 feat(project-picker): show auto-derived project code in parent typeahead
t-paliad-222 follow-up — wire .code into the parent-project picker so
two same-titled projects in different trees can be disambiguated by
their auto-derived dotted code. Search includes the code; the badge
renders only when distinct from the manual reference.

Excel __meta sheet still pending — the JSON code field is populated
by PopulateProjectCodes for every list payload, so the export
generator only needs to add one row in a follow-up shift.
2026-05-20 14:54:20 +02:00
mAi
9de14f0665 feat(projects-detail): render auto-derived project code as a second header badge
t-paliad-222 follow-up — wire the .code field populated by
PopulateProjectCodes into the project-detail header. Shows next to
the manual reference when distinct, hidden when they match (avoid
duplication) or when no segments resolved. CSS `.entity-ref-code`
adds bracket-styling so the user knows the value is derived rather
than typed.

Also extends the frontend Project interface with code + opponent_code
to make TypeScript surface the new fields cleanly across consumers.
2026-05-20 14:53:26 +02:00
mAi
d326acb31a feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.

Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
  seven sub-roles (claimant / defendant / applicant / appellant /
  respondent / third_party / other); drop legacy 'court' / 'both'
  and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
  text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
  the middle segment when assembling auto-derived project codes.

Backend:
- internal/services/project_code.go — new package-level helpers
  BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
  CTE-based round-trip). Walks the existing paliad.projects.path
  ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
  LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
  service entry-point that returns []models.Project / *models.Project
  populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
  (db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
  validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
  'both' rejected at the service layer with a clear error before
  the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
  appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
  ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
  "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
  better legal-prose default for a B2B patent practice, matches the
  form labels which already used this shape (cf. head's soft-note on
  Q4).

Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
  block (hidden by default, shown when type=litigation); our_side
  moved into projekt-fields-case and re-labelled "Client Role" /
  "Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
  block; readPayload / prefillForm wire opponent_code; our_side
  is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
  (Active→claimant, Reactive→defendant, Other→null). ProjectOption
  type literal updated.
- i18n.ts: new projects.field.client_role.* and
  projects.field.opponent_code.* keys (DE+EN). Legacy
  projects.field.our_side.* keys stay one release for cached
  bundles + Verlauf event-history rendering of the new sub-roles.

Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
  TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
  TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
  prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.

Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.

go build && go test ./internal/... && cd frontend && bun run build
all clean.
2026-05-20 14:50:19 +02:00
mAi
0a1a1d45ba Merge remote-tracking branch 'origin/main' into mai/kepler/inventorcoder-project 2026-05-20 14:47:53 +02:00
mAi
37cdf23c32 wip(projects): bump migrations 110→111, 111→112 (euler claimed 110) 2026-05-20 14:47:52 +02:00
mAi
e6353d907c Merge remote-tracking branch 'origin/main' into mai/kepler/inventorcoder-project 2026-05-20 14:45:38 +02:00
mAi
2cfd54f0cd wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
2026-05-20 14:45:33 +02:00
mAi
f99a32490d design(projects): t-paliad-222 — Client Role + auto-derived project codes
Design doc for paired m/paliad#47 (Client Role rework) + m/paliad#50
(auto-derived project codes from the ancestor tree). Two migrations
(110 widen our_side CHECK + backfill court/both → NULL; 111 add
opponent_code on litigations), one new BuildProjectCode helper that
walks the existing ltree path, plus form / submission-template /
Determinator wiring.

9 open design questions surfaced for the head; recommendations
default to the issue-body (R) picks unless a material concern is
flagged in §2.2 / §3.2.

Verified against live data (2026-05-20): all 12 projects have
our_side=NULL, so the backfill is a no-op on production today.
No 'opponent' field exists yet.
2026-05-20 14:27:09 +02:00
76 changed files with 1565 additions and 9460 deletions

View File

@@ -128,20 +128,6 @@ 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
@@ -151,11 +137,6 @@ 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,
@@ -184,11 +165,7 @@ func main() {
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
ChecklistCatalog: checklistCatalogSvc,
ChecklistTemplate: checklistTemplateSvc,
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
Mail: mailSvc,
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
@@ -201,9 +178,8 @@ func main() {
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
// t-paliad-214 Slice 1 — personal-scope data export. firm name
// is captured into __meta of every export and printed in the
@@ -218,16 +194,6 @@ func main() {
// without approvals — so keeping this a setter keeps both
// constructors simple).
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
// Slice C wires PinService into DashboardService for the
// pinned-projects widget. Pin pre-dates t-paliad-219; no new
// schema, no circular dependency (Pin doesn't know about the
// dashboard).
svcBundle.Dashboard.SetPinService(svcBundle.Pin)
// Slice C wires the firm-wide dashboard default into the
// per-user layout service so GetOrSeed/ResetToDefault prefer
// the admin-set firm default over the code-resident factory.
// Nil-safe: empty firm row falls back to the factory layout.
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
// t-paliad-215 Slice 1 — submission generator. Three services
// stitched together by handlers/submissions.go: registry pulls

View File

@@ -1,448 +0,0 @@
# 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

@@ -1,918 +0,0 @@
# 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,7 +10,6 @@ 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";
@@ -21,8 +20,10 @@ 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";
@@ -244,7 +245,6 @@ 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,8 +255,10 @@ 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"),
@@ -368,7 +370,6 @@ 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());
@@ -383,8 +384,10 @@ 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,9 +33,6 @@ 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>
@@ -135,67 +132,6 @@ 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

@@ -0,0 +1,103 @@
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

@@ -1,120 +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";
// 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,28 +39,12 @@ 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>
@@ -138,65 +122,6 @@ 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,10 +58,6 @@ 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>
@@ -122,21 +118,6 @@ 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,8 +34,6 @@ 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>
@@ -51,36 +49,6 @@ 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,125 +468,11 @@ 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

@@ -0,0 +1,193 @@
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

@@ -1,135 +0,0 @@
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

@@ -1,579 +0,0 @@
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

@@ -1,365 +0,0 @@
// 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,37 +30,6 @@ 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 {
@@ -402,320 +371,13 @@ 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 (async () => {
me = await loadMe();
await loadTemplate();
applyOwnerControls();
})();
void loadTemplate();
void loadInstances();
void loadAkten();
});

View File

@@ -40,16 +40,6 @@ 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;
@@ -165,119 +155,6 @@ 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() {
@@ -512,7 +389,6 @@ document.addEventListener("DOMContentLoaded", () => {
initPrint();
initRename();
initFeedback();
initDiffModal();
onLangChange(renderAll);
void bootstrap();
});

View File

@@ -11,26 +11,6 @@ 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 {
@@ -46,20 +26,15 @@ interface ChecklistInstance {
project_title?: string | null;
}
type TabId = "templates" | "mine" | "gallery" | "instances";
type TabId = "templates" | "instances";
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
const VALID_TABS: TabId[] = ["templates", "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 {
@@ -233,10 +208,7 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
if (opts.pushHistory ?? true) {
let newURL = "/checklists";
if (tab === "instances") newURL = "/checklists?tab=instances";
if (tab === "mine") newURL = "/checklists?tab=mine";
if (tab === "gallery") newURL = "/checklists?tab=gallery";
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
if (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
@@ -244,155 +216,6 @@ 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() {
@@ -411,15 +234,11 @@ 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,7 +1,6 @@
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
import { openModal } from "./components/modal";
interface DashboardUser {
id: string;
@@ -9,7 +8,6 @@ interface DashboardUser {
display_name: string;
office: string;
role: string;
global_role?: string;
}
interface DeadlineSummary {
@@ -68,13 +66,6 @@ interface DashboardData {
upcoming_appointments: UpcomingAppointment[];
recent_activity: ActivityEntry[];
inbox_summary?: InboxSummary;
pinned_projects?: PinnedProjectRef[];
}
interface PinnedProjectRef {
project_id: string;
project_title: string;
project_reference: string;
}
interface InboxEntry {
@@ -107,48 +98,15 @@ interface DashboardLayoutSpec {
widgets: DashboardWidgetRef[];
}
// WidgetDef mirrors services.WidgetDef (Go side). Only the fields the
// client actually reads are typed here; the picker modal needs the
// titles + descriptions in both languages, and the settings schema so
// the gear popover can render the right knobs.
interface WidgetSettingsSchema {
count_options?: number[];
horizon_options?: number[];
count_allows_all?: boolean;
}
interface WidgetCatalogEntry {
key: string;
title_de: string;
title_en: string;
description_de: string;
description_en: string;
default_visible: boolean;
default_count?: number;
default_horizon_days?: number;
settings?: WidgetSettingsSchema | null;
}
declare global {
interface Window {
__PALIAD_DASHBOARD__?: DashboardData | null;
__PALIAD_DASHBOARD_LAYOUT__?: DashboardLayoutSpec | null;
__PALIAD_DASHBOARD_CATALOG__?: WidgetCatalogEntry[] | null;
__PALIAD_DASHBOARD_CATALOG__?: unknown;
}
}
let currentLayout: DashboardLayoutSpec | null = null;
let currentCatalog: WidgetCatalogEntry[] = [];
let editMode = false;
// Pending PUT debounce — 400ms per design §6.4. Every layout mutation
// (drag, ↑/↓, hide, add, settings change) calls scheduleSave(); only
// the last write inside a 400ms window goes to the wire. A successful
// PUT flashes the .dashboard-save-toast briefly; a failed PUT rolls
// the layout back to its pre-mutation snapshot and surfaces the
// "Speichern fehlgeschlagen" toast so the user can retry.
let saveTimer: number | null = null;
let saveSnapshot: DashboardLayoutSpec | null = null;
const SAVE_DEBOUNCE_MS = 400;
// settingsFor returns the (possibly-empty) settings blob for a given
// widget key in the active layout. Falls back to an empty object so
@@ -199,12 +157,7 @@ function render(): void {
renderAgenda();
renderActivity(data.recent_activity);
renderInbox(data.inbox_summary ?? { pending_count: 0, top: [] });
renderPinnedProjects(data.pinned_projects ?? []);
// quick-actions is pure markup — nothing to render dynamically; the
// anchor URLs are baked into dashboard.tsx and only need i18n which
// applyTranslations already handles.
toggleOnboardingHint(data.user);
syncPromoteButtonVisibility(data.user);
// Apply the saved layout AFTER renderers so the per-widget settings
// applied above (count truncation, horizon filtering) are stable
// before we toggle visibility + reorder. Failing to find the layout
@@ -598,608 +551,6 @@ function renderInbox(s: InboxSummary): void {
}).join("");
}
// --- Edit mode (t-paliad-219 Slice B) -------------------------------------
//
// The Anpassen toggle in the dashboard header flips `editMode` and the
// `body.dashboard-editing` class. When on, every visible widget grows
// a chrome strip with drag handle / ↑ / ↓ / hide / gear; an edit
// footer below the activity widget surfaces the picker + reset. CSS
// hides the chrome when off so unstyled fallback (no JS) is fine.
//
// Chrome is rendered dynamically into a `.dashboard-widget__chrome`
// element appended to each [data-widget-key] element. Toggling edit
// mode rebinds handlers each time — the chrome is destroyed on exit
// so view mode has zero edit-related DOM cost.
//
// Persistence is autosave-on-every-change with a 400ms debounce. See
// scheduleSave() / flushSave().
function lookupCatalog(key: string): WidgetCatalogEntry | undefined {
for (const def of currentCatalog) if (def.key === key) return def;
return undefined;
}
function ensureLayout(): DashboardLayoutSpec {
if (currentLayout) return currentLayout;
// Fallback: derive a layout from the catalog so edit-mode mutations
// have a coherent draft even when the server didn't inline one. The
// next PUT seeds the user's row server-side.
currentLayout = {
v: 1,
widgets: currentCatalog
.filter((d) => d.default_visible)
.map((d) => ({ key: d.key, visible: true, settings: defaultSettings(d) })),
};
return currentLayout;
}
function defaultSettings(def: WidgetCatalogEntry): { count?: number; horizon_days?: number } | undefined {
const out: { count?: number; horizon_days?: number } = {};
if (typeof def.default_count === "number") out.count = def.default_count;
if (typeof def.default_horizon_days === "number") out.horizon_days = def.default_horizon_days;
return Object.keys(out).length ? out : undefined;
}
function snapshotLayout(spec: DashboardLayoutSpec): DashboardLayoutSpec {
return JSON.parse(JSON.stringify(spec));
}
function widgetLabel(key: string): string {
const def = lookupCatalog(key);
if (!def) return key;
return getLang() === "de" ? def.title_de : def.title_en;
}
function widgetDescription(key: string): string {
const def = lookupCatalog(key);
if (!def) return "";
return getLang() === "de" ? def.description_de : def.description_en;
}
function initEditToggle(): void {
const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", () => setEditMode(!editMode));
syncEditToggleLabel();
}
function setEditMode(on: boolean): void {
editMode = on;
document.body.classList.toggle("dashboard-editing", on);
const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
if (btn) btn.setAttribute("aria-pressed", String(on));
syncEditToggleLabel();
rebuildEditChrome();
// Close any open gear popover when leaving edit mode.
if (!on) closeGearPopover();
}
function syncEditToggleLabel(): void {
const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.textContent = editMode ? t("dashboard.edit.exit") : t("dashboard.edit.toggle");
}
// rebuildEditChrome removes any existing chrome and, when editMode is
// on, paints fresh chrome onto every [data-widget-key] element + binds
// the handlers. The chrome is dynamic so view-mode DOM stays lean.
function rebuildEditChrome(): void {
document.querySelectorAll<HTMLElement>(".dashboard-widget__chrome").forEach((el) => el.remove());
document.querySelectorAll<HTMLElement>("[data-widget-key]").forEach((el) => {
el.removeAttribute("draggable");
el.classList.remove("dashboard-widget");
el.classList.remove("dashboard-widget--hidden");
el.classList.remove("dashboard-widget--dragover");
});
if (!editMode) return;
const layout = ensureLayout();
document.querySelectorAll<HTMLElement>("[data-widget-key]").forEach((el) => {
const key = el.dataset.widgetKey || "";
if (!key) return;
const entry = layout.widgets.find((w) => w.key === key);
const visible = entry ? entry.visible : true;
el.classList.add("dashboard-widget");
if (!visible) {
el.classList.add("dashboard-widget--hidden");
// Hidden widgets are display:none in view mode (applyLayout);
// in edit mode we want them visible-but-dimmed so the user can
// un-hide them inline.
el.style.display = "";
}
const def = lookupCatalog(key);
const chrome = buildChrome(key, visible, def);
el.insertBefore(chrome, el.firstChild);
el.setAttribute("draggable", "true");
bindDnDHandlers(el);
});
}
function buildChrome(key: string, visible: boolean, def: WidgetCatalogEntry | undefined): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "dashboard-widget__chrome";
const handle = document.createElement("span");
handle.className = "dashboard-widget__handle";
handle.setAttribute("aria-hidden", "true");
handle.title = t("dashboard.edit.drag");
handle.textContent = "⠇⠇"; // ⠇⠇ a tighter drag-glyph than ⋮⋮
const label = document.createElement("span");
label.className = "dashboard-widget__label";
label.textContent = widgetLabel(key);
const actions = document.createElement("span");
actions.className = "dashboard-widget__actions";
const upBtn = chromeButton("↑", t("dashboard.edit.move_up") + ": " + widgetLabel(key), () => moveWidget(key, -1));
const downBtn = chromeButton("↓", t("dashboard.edit.move_down") + ": " + widgetLabel(key), () => moveWidget(key, +1));
const hideBtn = chromeButton(visible ? "×" : "+", t(visible ? "dashboard.edit.hide" : "dashboard.picker.status.hidden") + ": " + widgetLabel(key), () => toggleHidden(key));
hideBtn.classList.add(visible ? "dashboard-widget__hide" : "dashboard-widget__show");
actions.append(upBtn, downBtn, hideBtn);
if (def && def.settings && (def.settings.count_options?.length || def.settings.horizon_options?.length)) {
const gearBtn = chromeButton("⚙", t("dashboard.edit.settings") + ": " + widgetLabel(key), (ev) => openGearPopover(key, ev.currentTarget as HTMLElement));
gearBtn.classList.add("dashboard-widget__gear");
actions.appendChild(gearBtn);
}
wrap.append(handle, label, actions);
return wrap;
}
function chromeButton(glyph: string, label: string, onClick: (ev: MouseEvent) => void): HTMLButtonElement {
const b = document.createElement("button");
b.type = "button";
b.className = "dashboard-widget__btn";
b.setAttribute("aria-label", label);
b.title = label;
b.textContent = glyph;
b.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
});
return b;
}
// --- Layout mutations ---------------------------------------------------
function moveWidget(key: string, delta: -1 | 1): void {
const layout = ensureLayout();
const idx = layout.widgets.findIndex((w) => w.key === key);
if (idx < 0) return;
const target = idx + delta;
if (target < 0 || target >= layout.widgets.length) return;
const tmp = layout.widgets[idx];
layout.widgets[idx] = layout.widgets[target];
layout.widgets[target] = tmp;
afterLayoutMutation();
}
function toggleHidden(key: string): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (entry) {
entry.visible = !entry.visible;
} else {
// Widget not yet in the layout — append visible (re-add path).
const def = lookupCatalog(key);
if (!def) return;
layout.widgets.push({ key, visible: true, settings: defaultSettings(def) });
}
afterLayoutMutation();
}
function reorderViaDnd(srcKey: string, destKey: string): void {
if (srcKey === destKey) return;
const layout = ensureLayout();
const src = layout.widgets.findIndex((w) => w.key === srcKey);
const dest = layout.widgets.findIndex((w) => w.key === destKey);
if (src < 0 || dest < 0) return;
const [moved] = layout.widgets.splice(src, 1);
layout.widgets.splice(dest, 0, moved);
afterLayoutMutation();
}
function updateWidgetSettings(key: string, patch: { count?: number; horizon_days?: number }): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) return;
entry.settings = { ...(entry.settings ?? {}), ...patch };
afterLayoutMutation();
}
// afterLayoutMutation is the single funnel post-mutation:
// 1. re-render the widget stack so per-widget settings + ordering
// take effect immediately (count truncation, horizon filtering),
// 2. rebuild edit chrome so the up/down/hide affordances reflect
// the new state,
// 3. schedule a debounced PUT.
function afterLayoutMutation(): void {
if (data) render();
if (editMode) rebuildEditChrome();
scheduleSave();
}
// --- Autosave + reset ---------------------------------------------------
function scheduleSave(): void {
if (!currentLayout) return;
// Take a snapshot on the first dirty edit in a window — rollback
// target for failed saves.
if (saveSnapshot === null) saveSnapshot = snapshotLayout(currentLayout);
if (saveTimer !== null) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = null;
void flushSave();
}, SAVE_DEBOUNCE_MS);
}
async function flushSave(): Promise<void> {
if (!currentLayout) return;
const payload = snapshotLayout(currentLayout);
const rollback = saveSnapshot;
saveSnapshot = null;
try {
const resp = await fetch("/api/me/dashboard-layout", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`PUT /api/me/dashboard-layout: ${resp.status}`);
const fresh = (await resp.json()) as DashboardLayoutSpec;
currentLayout = fresh;
if (data) render();
if (editMode) rebuildEditChrome();
flashToast(t("dashboard.edit.saved"), "ok");
} catch (_e) {
if (rollback) currentLayout = rollback;
if (data) render();
if (editMode) rebuildEditChrome();
flashToast(t("dashboard.edit.save_failed"), "err");
}
}
function flashToast(message: string, kind: "ok" | "err"): void {
const el = document.getElementById("dashboard-save-toast");
if (!el) return;
el.textContent = message;
el.classList.remove("dashboard-save-toast--ok", "dashboard-save-toast--err", "dashboard-save-toast--show");
el.classList.add(kind === "ok" ? "dashboard-save-toast--ok" : "dashboard-save-toast--err");
// Force reflow before adding --show so the CSS transition replays
// when toasts fire back-to-back.
void el.offsetWidth;
el.classList.add("dashboard-save-toast--show");
window.setTimeout(() => el.classList.remove("dashboard-save-toast--show"), 1500);
}
function initEditFooter(): void {
const add = document.getElementById("dashboard-edit-add") as HTMLButtonElement | null;
const reset = document.getElementById("dashboard-edit-reset") as HTMLButtonElement | null;
const promote = document.getElementById("dashboard-edit-promote") as HTMLButtonElement | null;
add?.addEventListener("click", openPickerModal);
reset?.addEventListener("click", async () => {
if (!window.confirm(t("dashboard.edit.reset_confirm"))) return;
try {
const resp = await fetch("/api/me/dashboard-layout/reset", { method: "POST" });
if (!resp.ok) throw new Error(`POST reset: ${resp.status}`);
currentLayout = (await resp.json()) as DashboardLayoutSpec;
if (data) render();
if (editMode) rebuildEditChrome();
flashToast(t("dashboard.edit.saved"), "ok");
} catch (_e) {
flashToast(t("dashboard.edit.save_failed"), "err");
}
});
// Promote: admin-only convenience. POSTs to /api/me/dashboard-layout/
// promote which reads the admin's own current layout and stashes it
// into paliad.firm_dashboard_default. Subsequent user reseeds + resets
// land on this layout. Server enforces admin gate; the button hides
// for non-admins via syncPromoteButtonVisibility.
promote?.addEventListener("click", async () => {
if (!window.confirm(t("dashboard.edit.promote_confirm"))) return;
try {
const resp = await fetch("/api/me/dashboard-layout/promote", { method: "POST" });
if (!resp.ok) throw new Error(`POST promote: ${resp.status}`);
flashToast(t("dashboard.edit.promoted"), "ok");
} catch (_e) {
flashToast(t("dashboard.edit.save_failed"), "err");
}
});
}
// --- Picker modal -------------------------------------------------------
function openPickerModal(): void {
const layout = ensureLayout();
const body = document.createElement("div");
body.className = "widget-picker";
const list = document.createElement("ul");
list.className = "widget-picker__list";
body.appendChild(list);
renderPickerList(list, layout);
void openModal<void>({
title: t("dashboard.picker.title"),
body,
primary: { label: t("dashboard.picker.close"), handler: (close) => close() },
secondary: null,
size: "md",
});
}
function renderPickerList(list: HTMLUListElement, layout: DashboardLayoutSpec): void {
list.innerHTML = "";
if (!currentCatalog.length) {
const empty = document.createElement("li");
empty.className = "widget-picker__empty";
empty.textContent = t("dashboard.picker.empty");
list.appendChild(empty);
return;
}
for (const def of currentCatalog) {
const layoutEntry = layout.widgets.find((w) => w.key === def.key);
const status: "active" | "hidden" | "absent" = !layoutEntry
? "absent"
: layoutEntry.visible
? "active"
: "hidden";
const item = document.createElement("li");
item.className = `widget-picker__item widget-picker__item--${status}`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "widget-picker__btn";
btn.disabled = status === "active";
btn.addEventListener("click", () => {
handlePickerAdd(def.key);
renderPickerList(list, ensureLayout());
});
const title = document.createElement("span");
title.className = "widget-picker__title";
title.textContent = widgetLabel(def.key);
const desc = document.createElement("span");
desc.className = "widget-picker__desc";
desc.textContent = widgetDescription(def.key);
const pill = document.createElement("span");
pill.className = `widget-picker__pill widget-picker__pill--${status}`;
pill.textContent = t(
status === "active"
? "dashboard.picker.status.active"
: status === "hidden"
? "dashboard.picker.status.hidden"
: "dashboard.picker.status.absent",
);
btn.append(title, desc, pill);
item.appendChild(btn);
list.appendChild(item);
}
}
function handlePickerAdd(key: string): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) {
const def = lookupCatalog(key);
if (!def) return;
layout.widgets.push({ key, visible: true, settings: defaultSettings(def) });
} else if (!entry.visible) {
entry.visible = true;
} else {
return; // already active, no-op
}
afterLayoutMutation();
}
// --- Gear popover -------------------------------------------------------
let openGearAnchor: HTMLElement | null = null;
let openGearPopoverEl: HTMLElement | null = null;
let gearOutsideHandler: ((ev: MouseEvent) => void) | null = null;
let gearKeyHandler: ((ev: KeyboardEvent) => void) | null = null;
function openGearPopover(key: string, anchor: HTMLElement): void {
// Toggle off if the same gear is clicked again.
if (openGearAnchor === anchor) { closeGearPopover(); return; }
closeGearPopover();
const def = lookupCatalog(key);
if (!def || !def.settings) return;
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
const settings = entry?.settings ?? {};
const pop = document.createElement("div");
pop.className = "dashboard-widget__gear-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("dashboard.edit.settings"));
if (def.settings.count_options?.length) {
pop.appendChild(buildSettingRow(
t("dashboard.edit.setting.count"),
def.settings.count_options,
def.settings.count_allows_all,
typeof settings.count === "number" ? settings.count : def.default_count,
(value) => updateWidgetSettings(key, { count: value }),
(n) => String(n),
));
}
if (def.settings.horizon_options?.length) {
pop.appendChild(buildSettingRow(
t("dashboard.edit.setting.horizon"),
def.settings.horizon_options,
false,
typeof settings.horizon_days === "number" ? settings.horizon_days : def.default_horizon_days,
(value) => updateWidgetSettings(key, { horizon_days: value }),
(n) => t("dashboard.edit.setting.horizon.days").replace("{n}", String(n)),
));
}
// Position relative to the anchor button. Page coordinates so we
// don't have to chase a scrolling parent. The widget element is the
// positioning context.
const widget = anchor.closest<HTMLElement>("[data-widget-key]") || document.body;
widget.style.position = widget.style.position || "relative";
widget.appendChild(pop);
const rect = anchor.getBoundingClientRect();
const widgetRect = widget.getBoundingClientRect();
pop.style.position = "absolute";
pop.style.top = `${rect.bottom - widgetRect.top + 6}px`;
pop.style.right = `${widgetRect.right - rect.right}px`;
pop.style.zIndex = "20";
openGearAnchor = anchor;
openGearPopoverEl = pop;
gearOutsideHandler = (ev: MouseEvent) => {
const target = ev.target as Node;
if (pop.contains(target) || anchor.contains(target)) return;
closeGearPopover();
};
gearKeyHandler = (ev: KeyboardEvent) => { if (ev.key === "Escape") closeGearPopover(); };
// Defer attach so this click doesn't immediately close us.
window.setTimeout(() => {
document.addEventListener("mousedown", gearOutsideHandler!);
document.addEventListener("keydown", gearKeyHandler!);
}, 0);
}
function closeGearPopover(): void {
if (openGearPopoverEl) openGearPopoverEl.remove();
if (gearOutsideHandler) document.removeEventListener("mousedown", gearOutsideHandler);
if (gearKeyHandler) document.removeEventListener("keydown", gearKeyHandler);
openGearAnchor = null;
openGearPopoverEl = null;
gearOutsideHandler = null;
gearKeyHandler = null;
}
function buildSettingRow(
label: string,
options: number[],
allowsAll: boolean | undefined,
current: number | undefined,
onChange: (value: number) => void,
formatLabel: (n: number) => string,
): HTMLElement {
const row = document.createElement("label");
row.className = "dashboard-widget__gear-row";
const span = document.createElement("span");
span.className = "dashboard-widget__gear-label";
span.textContent = label;
row.appendChild(span);
const sel = document.createElement("select");
sel.className = "dashboard-widget__gear-select";
for (const opt of options) {
const o = document.createElement("option");
o.value = String(opt);
o.textContent = formatLabel(opt);
if (current === opt) o.selected = true;
sel.appendChild(o);
}
if (allowsAll) {
const o = document.createElement("option");
o.value = "-1";
o.textContent = t("dashboard.picker.status.active"); // best effort; "Alle" not in v1 keys
if (current === -1) o.selected = true;
sel.appendChild(o);
}
sel.addEventListener("change", () => onChange(parseInt(sel.value, 10)));
row.appendChild(sel);
return row;
}
// --- Drag and drop ------------------------------------------------------
let dragSourceKey: string | null = null;
function bindDnDHandlers(el: HTMLElement): void {
el.addEventListener("dragstart", (ev) => {
if (!editMode) return;
dragSourceKey = el.dataset.widgetKey || null;
if (ev.dataTransfer && dragSourceKey) {
ev.dataTransfer.setData("text/plain", dragSourceKey);
ev.dataTransfer.effectAllowed = "move";
}
el.classList.add("dashboard-widget--dragging");
});
el.addEventListener("dragover", (ev) => {
if (!editMode || !dragSourceKey) return;
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
el.classList.add("dashboard-widget--dragover");
});
el.addEventListener("dragleave", () => {
el.classList.remove("dashboard-widget--dragover");
});
el.addEventListener("drop", (ev) => {
if (!editMode || !dragSourceKey) return;
ev.preventDefault();
const destKey = el.dataset.widgetKey || "";
el.classList.remove("dashboard-widget--dragover");
if (destKey && dragSourceKey !== destKey) reorderViaDnd(dragSourceKey, destKey);
dragSourceKey = null;
});
el.addEventListener("dragend", () => {
el.classList.remove("dashboard-widget--dragging");
document.querySelectorAll<HTMLElement>(".dashboard-widget--dragover").forEach((d) => d.classList.remove("dashboard-widget--dragover"));
dragSourceKey = null;
});
}
// renderPinnedProjects (Slice C). Backend ships every visible pin (cap
// 20, pinned_at DESC); the widget's count setting trims further.
function renderPinnedProjects(items: PinnedProjectRef[]): void {
const list = document.getElementById("dashboard-pinned-list");
const empty = document.getElementById("dashboard-pinned-empty");
if (!list || !empty) return;
const s = settingsFor("pinned-projects");
const cap = typeof s.count === "number" && s.count > 0 ? s.count : 10;
items = items.slice(0, cap);
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.style.display = "";
list.innerHTML = items.map((p) => {
return `<li class="dashboard-list-item">
<a href="/projects/${esc(p.project_id)}" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(p.project_title)}</span>
${p.project_reference ? `<span class="dashboard-list-ref">${esc(p.project_reference)}</span>` : ""}
</div>
<div class="dashboard-list-meta" aria-hidden="true">★</div>
</a>
</li>`;
}).join("");
}
// syncPromoteButtonVisibility shows the "Set as firm default" button
// only to global_admins. The button is rendered in dashboard.tsx with
// style="display:none" so non-admins never see it even if the user
// payload is delayed; once we know the role we either reveal or leave
// it hidden.
function syncPromoteButtonVisibility(user: DashboardUser | null): void {
const btn = document.getElementById("dashboard-edit-promote") as HTMLButtonElement | null;
if (!btn) return;
btn.style.display = user && user.global_role === "global_admin" ? "" : "none";
}
// applyLayout walks the saved DashboardLayoutSpec and hides widgets whose
// keys are `visible: false`, then reorders the visible ones to match the
// layout's order. Widgets in the layout but missing from the DOM are
@@ -1230,19 +581,13 @@ function applyLayout(): void {
});
// Hide widgets whose layout entry says visible:false. Anything not in
// the layout at all stays untouched. In edit mode hidden widgets are
// kept rendered (dimmed via .dashboard-widget--hidden) so the user
// can re-show them in place.
// the layout at all stays untouched.
const seenInLayout = new Set<string>();
for (const w of currentLayout.widgets) {
seenInLayout.add(w.key);
const el = byKey.get(w.key);
if (!el) continue;
if (editMode) {
el.style.display = "";
} else {
el.style.display = w.visible ? "" : "none";
}
el.style.display = w.visible ? "" : "none";
}
// Reorder visible widgets inside each parent. We group widgets by their
@@ -1358,8 +703,6 @@ document.addEventListener("DOMContentLoaded", () => {
onLangChange(() => {
render();
syncCollapseAriaLabels();
syncEditToggleLabel();
if (editMode) rebuildEditChrome();
});
// Configurable layout (t-paliad-219). The Go shell handler splices
@@ -1379,24 +722,6 @@ document.addEventListener("DOMContentLoaded", () => {
}).catch(() => { /* silent — factory order is the fallback */ });
}
// Widget catalog — server-inlined by dashboard_shell.go so the
// picker / gear popover can render without a /api/dashboard-widget-
// catalog round-trip. Fall back to a fetch on hydration miss.
const catalogInline = window.__PALIAD_DASHBOARD_CATALOG__;
if (catalogInline && Array.isArray(catalogInline)) {
currentCatalog = catalogInline;
} else if (catalogInline === undefined) {
void fetch("/api/dashboard-widget-catalog").then(async (r) => {
if (!r.ok) return;
currentCatalog = (await r.json()) as WidgetCatalogEntry[];
}).catch(() => { /* silent — picker shows empty state */ });
}
// Edit-mode wiring (t-paliad-219 Slice B). Toggle starts off; chrome
// is built lazily on first activation so view-mode pays nothing.
initEditToggle();
initEditFooter();
// Inline agenda fetch is independent of the main dashboard payload.
// Kicked off in parallel so the agenda section paints as soon as the
// /api/agenda response lands instead of waiting on the dashboard

View File

@@ -0,0 +1,181 @@
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,7 +8,6 @@ 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
@@ -158,10 +157,8 @@ let me: Me | null = null;
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
let loadedOK = false;
// 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;
let calYear = 0;
let calMonth = 0;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
@@ -432,13 +429,12 @@ 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") {
renderCalendarView();
renderCalendar();
} else {
renderTable();
}
@@ -561,57 +557,135 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
</tr>`;
}
// 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;
// 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 {
if (item.type === "deadline") {
bucketDate = item.due_date ?? item.event_date;
} else if (item.start_at) {
bucketDate = item.start_at;
} else {
bucketDate = item.event_date;
const src = item.due_date ?? item.event_date;
return src.slice(0, 10);
}
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,
};
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")}`;
}
function renderCalendarView() {
const host = document.getElementById("events-calendar-wrap");
if (!host) return;
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")!;
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";
(host as HTMLElement).hidden = false;
wrap.hidden = false;
const items = allItems.map(toCalendarItem);
if (calendar) {
calendar.update(items);
return;
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]);
}
// 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 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!) ?? []));
});
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 teardownCalendar() {
if (!calendar) return;
calendar.destroy();
calendar = null;
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 applyView() {
@@ -632,18 +706,12 @@ 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 = 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.
// Calendar view = month grid; cards + table both hidden.
summary.style.display = currentView === "cards" ? "" : "none";
tableWrap.style.display = currentView === "calendar" ? "none" : "";
calWrap.hidden = currentView !== "calendar";
if (currentView === "calendar") {
if (loadedOK) renderCalendarView();
} else {
teardownCalendar();
}
if (currentView === "calendar" && loadedOK) renderCalendar();
}
function wireRowHandlers(tbody: HTMLElement) {
@@ -945,10 +1013,12 @@ function initFilters() {
}
function initView() {
// 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.
// 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();
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.dataset.eventView as EventView;
@@ -958,6 +1028,31 @@ 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,101 +555,7 @@ 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",
@@ -788,6 +694,7 @@ 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",
@@ -910,6 +817,12 @@ 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",
@@ -1006,44 +919,6 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.inbox.full_link": "Vollst\u00e4ndigen Posteingang \u00f6ffnen \u2192",
"dashboard.inbox.entity.deadline": "Frist",
"dashboard.inbox.entity.appointment": "Termin",
// Edit-mode chrome (t-paliad-219 Slice B). The toggle in the
// dashboard header flips body.dashboard-editing; the keys below
// power the in-page chrome (drag handle, \u2191/\u2193, hide, gear, picker,
// reset) plus the autosave toast.
"dashboard.edit.toggle": "Anpassen",
"dashboard.edit.exit": "Fertig",
"dashboard.edit.add_widget": "Widget hinzuf\u00fcgen",
"dashboard.edit.reset": "Auf Standard zur\u00fccksetzen",
"dashboard.edit.reset_confirm": "Layout auf Standard zur\u00fccksetzen? Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
// Slice C: admin promote \u2014 visible only when global_role==global_admin.
"dashboard.edit.promote": "Als Firmen-Standard speichern",
"dashboard.edit.promote_confirm": "Dein aktuelles Layout als Firmen-Standard speichern? Neue Nutzer:innen und 'Auf Standard zur\u00fccksetzen' verwenden danach diese Vorlage.",
"dashboard.edit.promoted": "Als Firmen-Standard gespeichert",
// Slice C: pinned-projects widget (reuses PinService).
"dashboard.pinned.heading": "Angepinnte Akten",
"dashboard.pinned.empty": "Noch keine Akten angepinnt.",
"dashboard.pinned.full_link": "Alle Akten \u00f6ffnen \u2192",
// Slice C: quick-actions widget \u2014 pure UI affordances.
"dashboard.quick.heading": "Schnellzugriff",
"dashboard.quick.new_project": "+ Akte",
"dashboard.quick.new_deadline": "+ Frist",
"dashboard.quick.new_appointment": "+ Termin",
"dashboard.edit.move_up": "Nach oben bewegen",
"dashboard.edit.move_down": "Nach unten bewegen",
"dashboard.edit.hide": "Ausblenden",
"dashboard.edit.settings": "Einstellungen",
"dashboard.edit.drag": "Ziehen, um neu zu ordnen",
"dashboard.edit.saved": "Gespeichert",
"dashboard.edit.save_failed": "Speichern fehlgeschlagen",
"dashboard.edit.setting.count": "Anzahl",
"dashboard.edit.setting.horizon": "Zeitraum",
"dashboard.edit.setting.horizon.days": "{n} Tage",
"dashboard.picker.title": "Widget hinzuf\u00fcgen",
"dashboard.picker.status.active": "Aktiv",
"dashboard.picker.status.hidden": "Versteckt",
"dashboard.picker.status.absent": "Nicht hinzugef\u00fcgt",
"dashboard.picker.close": "Schlie\u00dfen",
"dashboard.picker.empty": "Alle Widgets sind hinzugef\u00fcgt.",
// Collapsible-section toggle a11y labels (t-paliad-162). Both states
// are needed because the aria-label flips with the expanded state.
"dashboard.section.collapse": "Abschnitt einklappen",
@@ -1725,6 +1600,7 @@ 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",
@@ -1780,6 +1656,11 @@ 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;
@@ -1803,6 +1684,7 @@ 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.",
@@ -2195,24 +2077,8 @@ 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.",
@@ -3416,101 +3282,7 @@ 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",
@@ -3649,6 +3421,7 @@ 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",
@@ -3771,6 +3544,12 @@ 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",
@@ -3800,7 +3579,6 @@ 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.",
@@ -3863,37 +3641,6 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.inbox.full_link": "Open full inbox →",
"dashboard.inbox.entity.deadline": "Deadline",
"dashboard.inbox.entity.appointment": "Appointment",
"dashboard.edit.toggle": "Customize",
"dashboard.edit.exit": "Done",
"dashboard.edit.add_widget": "Add widget",
"dashboard.edit.reset": "Reset to default",
"dashboard.edit.reset_confirm": "Reset layout to default? This cannot be undone.",
"dashboard.edit.promote": "Save as firm default",
"dashboard.edit.promote_confirm": "Save your current layout as the firm default? New users and 'Reset to default' will use this layout afterwards.",
"dashboard.edit.promoted": "Saved as firm default",
"dashboard.pinned.heading": "Pinned matters",
"dashboard.pinned.empty": "No pinned matters yet.",
"dashboard.pinned.full_link": "Open all matters →",
"dashboard.quick.heading": "Quick actions",
"dashboard.quick.new_project": "+ Matter",
"dashboard.quick.new_deadline": "+ Deadline",
"dashboard.quick.new_appointment": "+ Appointment",
"dashboard.edit.move_up": "Move up",
"dashboard.edit.move_down": "Move down",
"dashboard.edit.hide": "Hide",
"dashboard.edit.settings": "Settings",
"dashboard.edit.drag": "Drag to reorder",
"dashboard.edit.saved": "Saved",
"dashboard.edit.save_failed": "Save failed",
"dashboard.edit.setting.count": "Count",
"dashboard.edit.setting.horizon": "Horizon",
"dashboard.edit.setting.horizon.days": "{n} days",
"dashboard.picker.title": "Add widget",
"dashboard.picker.status.active": "Active",
"dashboard.picker.status.hidden": "Hidden",
"dashboard.picker.status.absent": "Not added",
"dashboard.picker.close": "Done",
"dashboard.picker.empty": "All widgets are already added.",
"dashboard.section.collapse": "Collapse section",
"dashboard.section.expand": "Expand section",
"dashboard.urgency.overdue": "Overdue",
@@ -4566,6 +4313,7 @@ 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",
@@ -4621,6 +4369,11 @@ 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",
@@ -4641,6 +4394,7 @@ 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.",
@@ -5031,24 +4785,8 @@ 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,13 +93,12 @@ 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

@@ -13,7 +13,6 @@ import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
calculateDeadlines,
escHtml,
formatDate,
populateCourtPicker,
renderColumnsBody,
@@ -158,19 +157,13 @@ async function doCalc() {
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank). Fallback respects language —
// proceedingNameEN is consulted on EN before the DE proceedingName
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
// healthy data, but safer than a blank).
function triggerEventLabelFor(data: DeadlineResponse): string {
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
if (getLang() === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
return data.proceedingName || "";
}
function syncTriggerEventLabel() {
@@ -200,23 +193,11 @@ function renderResults(data: DeadlineResponse) {
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
// Sub-track contextual note (m/paliad#58). Surfaces above the
// timeline body when the server routed the user-picked proceeding
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
// Plain-text banner — server-side copy is plain text per the
// SubTrackRouting contract.
const noteText = getLang() === "en"
? (data.contextualNoteEN || data.contextualNote || "")
: (data.contextualNote || data.contextualNoteEN || "");
const noteHtml = noteText
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
: "";
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";

View File

@@ -1,28 +1,525 @@
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// 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.
// 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;
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
const items: CalendarItem[] = rows.map(toCalendarItem);
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
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;
});
}
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,
};
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());
}

View File

@@ -95,21 +95,8 @@ export function priorityRendering(
export interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
// proceedingNameEN: English label of the picked proceeding. Empty
// when not populated server-side; frontend falls back to
// proceedingName. Used for the "Trigger event" fallback when the
// timeline has no root rule. (m/paliad#58)
proceedingNameEN?: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
// contextualNote / contextualNoteEN render as a banner above the
// timeline. Populated when the picked proceeding is a sub-track of
// another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
// with_ccr) — the server routes to the parent's rules but keeps the
// picked proceeding's identity in the response, and the note
// explains the framing. (m/paliad#58)
contextualNote?: string;
contextualNoteEN?: string;
}
export interface CourtRow {

View File

@@ -76,17 +76,6 @@ export function renderDashboard(): string {
<span className="dashboard-date" id="dashboard-date"></span>
</p>
</div>
{/* "Anpassen" toggle (t-paliad-219 Slice B). Off by
default — when on, body.dashboard-editing reveals
drag handles / ↑↓ / x / ⚙ chrome on each widget plus
the edit-footer below the widget stack. */}
<button
type="button"
id="dashboard-edit-toggle"
className="btn btn-ghost dashboard-edit-toggle"
aria-pressed="false"
data-i18n="dashboard.edit.toggle"
>Anpassen</button>
</div>
<div id="dashboard-unavailable" className="dashboard-unavailable" style="display:none">
@@ -217,66 +206,6 @@ export function renderDashboard(): string {
Noch keine Aktivit&auml;t erfasst.
</p>
</CollapsibleSection>
{/* Pinned-projects widget (t-paliad-219 Slice C). Reads
PinService via DashboardData.pinned_projects (server-
joined to titles + refs). Default-hidden — users opt
in via the picker. */}
<CollapsibleSection id="pinned-projects" widgetKey="pinned-projects" headingI18n="dashboard.pinned.heading" headingDe="Angepinnte Akten">
<ul className="dashboard-list" id="dashboard-pinned-list"></ul>
<p className="dashboard-empty" id="dashboard-pinned-empty" style="display:none" data-i18n="dashboard.pinned.empty">
Noch keine Akten angepinnt.
</p>
<p className="dashboard-agenda-link">
<a href="/projects" data-i18n="dashboard.pinned.full_link">Alle Akten &ouml;ffnen &rarr;</a>
</p>
</CollapsibleSection>
{/* Quick-actions widget (t-paliad-219 Slice C). Pure UI;
no backend data path. Default-hidden — surfaced via the
picker. */}
<CollapsibleSection id="quick-actions" widgetKey="quick-actions" headingI18n="dashboard.quick.heading" headingDe="Schnellzugriff">
<div className="dashboard-quick-actions">
<a href="/projects/new" className="btn btn-primary dashboard-quick-btn" data-i18n="dashboard.quick.new_project">+ Akte</a>
<a href="/deadlines/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_deadline">+ Frist</a>
<a href="/appointments/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_appointment">+ Termin</a>
</div>
</CollapsibleSection>
{/* Edit-mode footer (t-paliad-219 Slice B). Hidden via CSS
unless body.dashboard-editing — see dashboard.ts.
Slice C added the admin "Promote to firm default"
button — it stays hidden unless data.user.global_role
is 'global_admin'; dashboard.ts toggles it. */}
<div id="dashboard-edit-footer" className="dashboard-edit-footer">
<button
type="button"
id="dashboard-edit-add"
className="btn btn-secondary dashboard-edit-add"
data-i18n="dashboard.edit.add_widget"
>Widget hinzuf&uuml;gen</button>
<button
type="button"
id="dashboard-edit-promote"
className="btn btn-ghost dashboard-edit-promote"
style="display:none"
data-i18n="dashboard.edit.promote"
>Als Firmen-Standard speichern</button>
<button
type="button"
id="dashboard-edit-reset"
className="dashboard-edit-reset-link"
data-i18n="dashboard.edit.reset"
>Auf Standard zur&uuml;cksetzen</button>
</div>
{/* Save toast slot — managed by dashboard.ts. */}
<div
id="dashboard-save-toast"
className="dashboard-save-toast"
role="status"
aria-live="polite"
></div>
</div>
</section>
</main>

View File

@@ -0,0 +1,84 @@
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,10 +236,37 @@ export function renderEvents(): string {
</table>
</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 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>
<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,23 +440,7 @@ 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"
@@ -571,6 +555,12 @@ 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"
@@ -715,7 +705,6 @@ export type I18nKey =
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.today"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
@@ -800,54 +789,7 @@ 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"
@@ -865,23 +807,11 @@ 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"
@@ -904,18 +834,6 @@ 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"
@@ -932,31 +850,8 @@ 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"
@@ -1032,24 +927,6 @@ export type I18nKey =
| "dashboard.appointments.heading"
| "dashboard.deadlines.empty"
| "dashboard.deadlines.heading"
| "dashboard.edit.add_widget"
| "dashboard.edit.drag"
| "dashboard.edit.exit"
| "dashboard.edit.hide"
| "dashboard.edit.move_down"
| "dashboard.edit.move_up"
| "dashboard.edit.promote"
| "dashboard.edit.promote_confirm"
| "dashboard.edit.promoted"
| "dashboard.edit.reset"
| "dashboard.edit.reset_confirm"
| "dashboard.edit.save_failed"
| "dashboard.edit.saved"
| "dashboard.edit.setting.count"
| "dashboard.edit.setting.horizon"
| "dashboard.edit.setting.horizon.days"
| "dashboard.edit.settings"
| "dashboard.edit.toggle"
| "dashboard.greeting.prefix"
| "dashboard.inbox.empty"
| "dashboard.inbox.entity.appointment"
@@ -1061,19 +938,6 @@ export type I18nKey =
| "dashboard.matters.heading"
| "dashboard.matters.total"
| "dashboard.onboarding"
| "dashboard.picker.close"
| "dashboard.picker.empty"
| "dashboard.picker.status.absent"
| "dashboard.picker.status.active"
| "dashboard.picker.status.hidden"
| "dashboard.picker.title"
| "dashboard.pinned.empty"
| "dashboard.pinned.full_link"
| "dashboard.pinned.heading"
| "dashboard.quick.heading"
| "dashboard.quick.new_appointment"
| "dashboard.quick.new_deadline"
| "dashboard.quick.new_project"
| "dashboard.section.collapse"
| "dashboard.section.expand"
| "dashboard.summary.completed"
@@ -1256,6 +1120,13 @@ 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"
@@ -1583,6 +1454,7 @@ 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

@@ -3304,23 +3304,6 @@ input[type="range"]::-moz-range-thumb {
font-size: 1rem;
}
/* Sub-track contextual note banner (m/paliad#58). Renders above the
timeline body when the picked proceeding is a sub-track of another
proceeding (e.g. UPC CCR rendered standalone). Plain-text content;
white-space: pre-line preserves paragraph breaks if server copy
ever uses them. */
.timeline-context-note {
margin: 0 0 1rem;
padding: 0.7rem 0.9rem;
background: rgba(198, 244, 28, 0.10);
border-left: 3px solid var(--brand-lime, #c6f41c);
border-radius: 4px;
color: var(--color-text, #222);
font-size: 0.9rem;
line-height: 1.4;
white-space: pre-line;
}
.timeline {
position: relative;
}
@@ -7470,10 +7453,158 @@ dialog.modal::backdrop {
max-width: 22rem;
}
/* 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). */
/* 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);
}
/* Fristenrechner save-to-Akte modal */
@@ -7879,6 +8010,9 @@ dialog.modal::backdrop {
.frist-summary-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.frist-cal-cell {
min-height: 64px;
}
}
/* ========================================================================
@@ -8425,344 +8559,6 @@ dialog.modal::backdrop {
min-width: 0;
}
/* ---------------------------------------------------------------------------
Dashboard edit mode (t-paliad-219 Slice B)
---------------------------------------------------------------------------
The Anpassen toggle in the dashboard header flips body.dashboard-editing.
In view mode every .dashboard-widget__* selector is a no-op (chrome is
only injected dynamically by client/dashboard.ts on toggle-on). Footer
+ reset are CSS-hidden until editing.
--------------------------------------------------------------------------- */
.dashboard-edit-toggle {
align-self: flex-start;
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
}
.dashboard-edit-footer {
display: none;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 2rem 0 1rem;
padding: 1rem 1.25rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius);
background: var(--color-surface-muted, var(--color-surface));
flex-wrap: wrap;
}
body.dashboard-editing .dashboard-edit-footer {
display: flex;
}
.dashboard-edit-add {
font-size: 0.9rem;
}
.dashboard-edit-reset-link {
background: none;
border: 0;
padding: 0.2rem 0.4rem;
color: var(--color-text-muted);
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.dashboard-edit-reset-link:hover,
.dashboard-edit-reset-link:focus-visible {
color: var(--color-text);
}
/* Chrome strip injected on every [data-widget-key] when editing. The
widget itself becomes a positioning context so the gear popover can
anchor with absolute positioning. */
body.dashboard-editing [data-widget-key].dashboard-widget {
position: relative;
outline: 1px dashed var(--color-border);
outline-offset: 4px;
border-radius: var(--radius);
transition: outline-color 0.12s ease;
}
body.dashboard-editing [data-widget-key].dashboard-widget--hidden {
opacity: 0.45;
}
body.dashboard-editing [data-widget-key].dashboard-widget--dragging {
opacity: 0.5;
}
body.dashboard-editing [data-widget-key].dashboard-widget--dragover {
outline-color: var(--color-accent-fg);
outline-style: solid;
outline-width: 2px;
}
.dashboard-widget__chrome {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
margin: 0 0 0.6rem;
background: var(--color-surface-muted, var(--color-surface));
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.8rem;
color: var(--color-text-muted);
}
.dashboard-widget__handle {
cursor: grab;
color: var(--color-text-muted);
letter-spacing: -0.1em;
user-select: none;
}
.dashboard-widget__handle:active {
cursor: grabbing;
}
.dashboard-widget__label {
flex: 1;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-widget__actions {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.dashboard-widget__btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.7rem;
height: 1.7rem;
padding: 0 0.4rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
}
.dashboard-widget__btn:hover,
.dashboard-widget__btn:focus-visible {
border-color: var(--color-accent-fg);
color: var(--color-accent-fg);
}
.dashboard-widget__hide:hover {
color: var(--status-red-fg, #b91c1c);
border-color: currentColor;
}
/* Gear popover — absolutely positioned inside the .dashboard-widget. */
.dashboard-widget__gear-popover {
min-width: 180px;
padding: 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dashboard-widget__gear-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.85rem;
}
.dashboard-widget__gear-label {
color: var(--color-text-muted);
}
.dashboard-widget__gear-select {
min-width: 80px;
padding: 0.2rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.85rem;
}
/* Widget picker modal body */
.widget-picker__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.widget-picker__empty {
color: var(--color-text-muted);
font-style: italic;
padding: 1rem 0;
text-align: center;
}
.widget-picker__item {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
}
.widget-picker__btn {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: 0.75rem;
row-gap: 0.15rem;
width: 100%;
padding: 0.7rem 0.9rem;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
.widget-picker__btn:hover:not([disabled]),
.widget-picker__btn:focus-visible:not([disabled]) {
background: var(--color-surface-muted, var(--color-surface));
}
.widget-picker__btn[disabled] {
cursor: default;
opacity: 0.7;
}
.widget-picker__title {
grid-column: 1;
font-weight: 600;
color: var(--color-text);
}
.widget-picker__desc {
grid-column: 1;
grid-row: 2;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.widget-picker__pill {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.widget-picker__pill--active {
background: var(--status-green-bg, rgba(110, 220, 130, 0.18));
color: var(--status-green-fg, #166534);
}
.widget-picker__pill--hidden {
background: var(--status-amber-bg, rgba(255, 200, 110, 0.18));
color: var(--status-amber-fg, #92400e);
}
.widget-picker__pill--absent {
background: var(--color-surface-muted, rgba(0, 0, 0, 0.06));
color: var(--color-text-muted);
}
/* Autosave toast */
.dashboard-save-toast {
position: fixed;
right: 1.5rem;
bottom: calc(var(--bottom-nav-height, 1rem) + 1rem);
padding: 0.55rem 0.9rem;
border-radius: var(--radius);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: 0.85rem;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.18s ease, transform 0.18s ease;
pointer-events: none;
z-index: 60;
}
.dashboard-save-toast--show {
opacity: 1;
transform: translateY(0);
}
.dashboard-save-toast--err {
border-color: var(--status-red-fg, #b91c1c);
color: var(--status-red-fg, #b91c1c);
}
/* Mobile fallback (design §6.8) — single column already collapses via the
.dashboard-columns media query. The toggle becomes a wider tappable
button; drag handle is hidden in favor of the ↑/↓ buttons since
touch DnD is unreliable. The picker modal goes full-screen via the
modal primitive's existing @media (max-width: 32rem) rule. */
@media (max-width: 32rem) {
.dashboard-edit-toggle {
align-self: stretch;
width: 100%;
padding: 0.6rem 0.8rem;
}
body.dashboard-editing .dashboard-widget__handle {
display: none;
}
.dashboard-save-toast {
left: 1rem;
right: 1rem;
text-align: center;
}
}
/* ---------------------------------------------------------------------------
Slice C: pinned-projects + quick-actions widgets (t-paliad-219)
--------------------------------------------------------------------------- */
.dashboard-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dashboard-quick-btn {
flex: 1 1 auto;
text-align: center;
min-width: 8rem;
}
.dashboard-edit-promote {
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
}
/* ---------------------------------------------------------------------------
Inline Agenda on the dashboard (t-paliad-162)
--------------------------------------------------------------------------- */
@@ -8875,6 +8671,27 @@ body.dashboard-editing [data-widget-key].dashboard-widget--dragover {
.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);
@@ -11693,13 +11510,18 @@ dialog.quick-add-sheet::backdrop {
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
}
/* 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). */
/* 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. */
.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

@@ -1,13 +0,0 @@
-- 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

@@ -1,178 +0,0 @@
-- 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

@@ -1,26 +0,0 @@
-- 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

@@ -1,211 +0,0 @@
-- 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

@@ -1,7 +0,0 @@
-- 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

@@ -1,39 +0,0 @@
-- 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

@@ -1 +0,0 @@
DROP TABLE IF EXISTS paliad.firm_dashboard_default;

View File

@@ -1,33 +0,0 @@
-- t-paliad-219 Slice C: firm-wide dashboard default layout.
--
-- Design: docs/design-dashboard-configurable-2026-05-20.md §8.2 (firm-wide
-- admin default, deferred to v1.1 — now activated by m's Slice C brief).
--
-- A single optional row that holds the firm's preferred dashboard layout.
-- DashboardLayoutService.GetOrSeed reads this on first call for a new user
-- (falling back to the code-resident FactoryDefaultLayout when null);
-- ResetToDefault similarly prefers the firm default. Admins promote their
-- own current layout into this row via POST /api/me/dashboard-layout/promote.
--
-- Single-row design via CHECK (id = 1) so there's no ambiguity about which
-- row is "the default". RLS lets any authenticated user SELECT (so the
-- service can read it during seed); only the application (service-role
-- connection) writes — the admin gate sits on the HTTP handler.
CREATE TABLE paliad.firm_dashboard_default (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
layout_json jsonb NOT NULL,
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE paliad.firm_dashboard_default ENABLE ROW LEVEL SECURITY;
-- All authenticated users can SELECT — the dashboard seed path needs to
-- read it for every new user. The HTTP handler enforces admin-only on the
-- PUT/DELETE paths; the service runs under service-role so writes bypass
-- RLS anyway. No INSERT/UPDATE policy means no Supabase-JWT-authenticated
-- client can write, which is the desired posture.
CREATE POLICY firm_dashboard_default_read
ON paliad.firm_dashboard_default FOR SELECT
USING (true);

View File

@@ -44,78 +44,6 @@ 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,13 +24,8 @@ 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.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
http.ServeFile(w, r, "dist/appointments-calendar.html")
}
// handleSettingsPage serves the unified settings page with tabs for

View File

@@ -1,131 +0,0 @@
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

@@ -1,133 +0,0 @@
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,13 +24,6 @@ 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 {
@@ -44,105 +37,18 @@ 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) {
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)
writeJSON(w, http.StatusOK, checklists.Summaries())
}
// 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")
// 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
}
uid, ok := requireUser(w, r)
c, ok := checklists.Find(slug)
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,
})
writeJSON(w, http.StatusOK, c)
}
func handleChecklistsFeedback(w http.ResponseWriter, r *http.Request) {

View File

@@ -23,13 +23,6 @@ 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.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
http.ServeFile(w, r, "dist/deadlines-calendar.html")
}

View File

@@ -1,139 +0,0 @@
package handlers
// HTTP handlers for the firm-wide dashboard default layout (t-paliad-219
// Slice C). All four endpoints sit behind the adminGate so only
// global_admin can read or mutate. The per-user GetOrSeed/ResetToDefault
// path consumes the firm default via DashboardLayoutService — the read
// surface here is just the admin's read-back of the current row.
//
// GET /api/admin/firm-dashboard-default — current row, or 204
// PUT /api/admin/firm-dashboard-default — replace
// DELETE /api/admin/firm-dashboard-default — clear (revert to factory)
// POST /api/me/dashboard-layout/promote — promote caller's own
// current layout to firm
// default. Admin convenience.
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/admin/firm-dashboard-default — returns the current firm-wide
// default layout, or 204 when none is set. Admins read this on the
// firm-default admin surface to verify the active layout.
func handleGetFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
spec, ok, err := dbSvc.firmDashboardDefault.Get(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
if !ok {
// Empty firm default — the caller can fall back to the factory
// shape via GET /api/dashboard-widget-catalog + FactoryDefault-
// Layout logic mirrored client-side. 204 is cheaper than
// shipping an "is_set: false" wrapper.
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, spec)
}
// PUT /api/admin/firm-dashboard-default — replace the firm-wide default.
// Body must be a complete DashboardLayoutSpec. The admin is recorded as
// updated_by for audit.
func handlePutFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
var spec services.DashboardLayoutSpec
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// DELETE /api/admin/firm-dashboard-default — clear the firm default so
// future seeds/resets revert to the code-resident FactoryDefaultLayout.
// Idempotent.
func handleDeleteFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
if err := dbSvc.firmDashboardDefault.Clear(r.Context()); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/me/dashboard-layout/promote — admin convenience. Takes the
// caller's current layout (whatever's in user_dashboard_layouts for
// them) and promotes it to the firm-wide default. Saves an admin the
// step of crafting a layout in a JSON editor — they edit their own
// dashboard via the standard /dashboard editor, then promote one click.
//
// Admin-only at the route level (handlers.go wires this under adminGate).
// The handler itself does not re-check admin — that's the gate's job.
func handlePromoteDashboardLayoutToFirmDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil || dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
// Read the admin's own current layout (seeding the factory if they
// somehow lack a row — vanishingly unlikely for an admin who's
// logging in to promote, but the safety belt costs nothing).
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}

View File

@@ -70,11 +70,7 @@ type Services struct {
EventType *services.EventTypeService
Dashboard *services.DashboardService
Note *services.NoteService
ChecklistInst *services.ChecklistInstanceService
ChecklistCatalog *services.ChecklistCatalogService
ChecklistTemplate *services.ChecklistTemplateService
ChecklistShare *services.ChecklistShareService
ChecklistPromotion *services.ChecklistPromotionService
ChecklistInst *services.ChecklistInstanceService
Mail *services.MailService
Invite *services.InviteService
Agenda *services.AgendaService
@@ -90,11 +86,6 @@ type Services struct {
Pin *services.PinService
CardLayout *services.CardLayoutService
DashboardLayout *services.DashboardLayoutService
// FirmDashboardDefault is the firm-wide /dashboard default layout
// (Slice C). Admin-only writes; per-user seed/reset reads it via
// DashboardLayoutService.defaultLayout(). Nil-safe — falls back to
// the code-resident FactoryDefaultLayout.
FirmDashboardDefault *services.FirmDashboardDefaultService
Projection *services.ProjectionService
Export *services.ExportService
@@ -153,11 +144,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
eventType: svc.EventType,
dashboard: svc.Dashboard,
note: svc.Note,
checklistInst: svc.ChecklistInst,
checklistCatalog: svc.ChecklistCatalog,
checklistTemplate: svc.ChecklistTemplate,
checklistShare: svc.ChecklistShare,
checklistPromotion: svc.ChecklistPromotion,
checklistInst: svc.ChecklistInst,
mail: svc.Mail,
invite: svc.Invite,
agenda: svc.Agenda,
@@ -173,7 +160,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
pin: svc.Pin,
cardLayout: svc.CardLayout,
dashboardLayout: svc.DashboardLayout,
firmDashboardDefault: svc.FirmDashboardDefault,
projection: svc.Projection,
export: svc.Export,
}
@@ -262,25 +248,11 @@ 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)
@@ -537,17 +509,10 @@ 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))
protected.HandleFunc("GET /api/audit-log", adminGate(users, handleListAuditLog))
// t-paliad-219 Slice C — firm-wide dashboard default + admin promote.
protected.HandleFunc("GET /api/admin/firm-dashboard-default", adminGate(users, handleGetFirmDashboardDefault))
protected.HandleFunc("PUT /api/admin/firm-dashboard-default", adminGate(users, handlePutFirmDashboardDefault))
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))

View File

@@ -38,11 +38,7 @@ type dbServices struct {
eventType *services.EventTypeService
dashboard *services.DashboardService
note *services.NoteService
checklistInst *services.ChecklistInstanceService
checklistCatalog *services.ChecklistCatalogService
checklistTemplate *services.ChecklistTemplateService
checklistShare *services.ChecklistShareService
checklistPromotion *services.ChecklistPromotionService
checklistInst *services.ChecklistInstanceService
mail *services.MailService
invite *services.InviteService
agenda *services.AgendaService
@@ -58,7 +54,6 @@ type dbServices struct {
pin *services.PinService
cardLayout *services.CardLayoutService
dashboardLayout *services.DashboardLayoutService
firmDashboardDefault *services.FirmDashboardDefaultService
projection *services.ProjectionService
export *services.ExportService
}

View File

@@ -32,28 +32,3 @@ 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,32 +421,22 @@ type Note struct {
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
}
// 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.
// ChecklistInstance is one user's instantiation of a static checklist
// template (defined in internal/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"`
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"`
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"`
}
// ChecklistInstanceWithProject enriches an instance with its parent Project
@@ -457,37 +447,6 @@ 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

@@ -1,309 +0,0 @@
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,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -20,23 +21,17 @@ 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, catalog *ChecklistCatalogService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projects: projects, catalog: catalog}
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projects: projects}
}
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state,
ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot, ci.template_version`
ci.created_by, ci.created_at, ci.updated_at`
const checklistInstanceWithProjectSelect = `SELECT ` + checklistInstanceColumns + `,
p.reference AS project_reference,
@@ -60,11 +55,8 @@ 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 _, 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
if _, ok := checklists.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
user, err := s.projects.Users().GetByID(ctx, userID)
if err != nil {
@@ -132,25 +124,11 @@ func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.
return inst, nil
}
// 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).
// Create inserts a new instance.
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
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
if _, ok := checklists.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
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)
@@ -175,10 +153,9 @@ 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, 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,
(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,
); err != nil {
return nil, fmt.Errorf("insert checklist_instance: %w", err)
}
@@ -389,8 +366,7 @@ 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, template_snapshot, template_version
`SELECT id, template_slug, name, project_id, state, created_by, created_at, updated_at
FROM paliad.checklist_instances WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible

View File

@@ -1,153 +0,0 @@
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

@@ -1,331 +0,0 @@
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

@@ -1,107 +0,0 @@
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

@@ -1,586 +0,0 @@
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

@@ -1,129 +0,0 @@
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

@@ -22,8 +22,7 @@ import (
// DashboardLayoutService manages paliad.user_dashboard_layouts.
type DashboardLayoutService struct {
db *sqlx.DB
firmDefault *FirmDashboardDefaultService
db *sqlx.DB
}
// NewDashboardLayoutService wires the service.
@@ -31,29 +30,6 @@ func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService {
return &DashboardLayoutService{db: db}
}
// SetFirmDefaultService wires the firm-wide default source (Slice C).
// When set and non-empty, GetOrSeed/ResetToDefault prefer it over the
// code-resident FactoryDefaultLayout. nil-safe — when unwired or when
// the table is empty, behavior falls back to the code-resident default.
func (s *DashboardLayoutService) SetFirmDefaultService(f *FirmDashboardDefaultService) {
s.firmDefault = f
}
// defaultLayout returns the firm-wide default if one is set, else the
// code-resident FactoryDefaultLayout. Used by the seed and reset paths.
// On any error reading the firm row, falls back to the factory default
// so a transient DB blip can't strand a user without a dashboard.
func (s *DashboardLayoutService) defaultLayout(ctx context.Context) DashboardLayoutSpec {
if s.firmDefault == nil {
return FactoryDefaultLayout()
}
spec, ok, err := s.firmDefault.Get(ctx)
if err != nil || !ok {
return FactoryDefaultLayout()
}
return spec
}
// GetOrSeed returns the caller's saved layout. On first call for a user
// (no row), it inserts and returns the factory default. The seed is
// idempotent — concurrent first-loads converge to the same row via the
@@ -97,14 +73,9 @@ func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, s
return out, nil
}
// ResetToDefault overwrites the user's layout with the firm-wide default
// when one is set, otherwise with the code-resident factory default
// (Slice C). The single user-facing "Auf Standard zurücksetzen" link
// always lands the user on whatever the firm considers default at the
// time of the click — admins can update it later and users get the new
// default on their next reset.
// ResetToDefault overwrites the user's layout with the factory default.
func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
def := s.defaultLayout(ctx)
def := FactoryDefaultLayout()
if err := s.upsert(ctx, userID, def); err != nil {
return DashboardLayoutSpec{}, err
}
@@ -135,14 +106,11 @@ func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (D
return spec, true, nil
}
// seedFactoryDefault inserts the firm-wide default (if set) or the
// code-resident factory layout for a brand-new user. ON CONFLICT DO
// NOTHING handles the race where two concurrent first loads both miss
// the SELECT and both try to insert. Function name kept as "Factory"
// even though it may now seed the firm default — renaming the call site
// would churn one file for no callsite benefit.
// seedFactoryDefault inserts the factory layout for a brand-new user.
// ON CONFLICT DO NOTHING handles the race where two concurrent first
// loads both miss the SELECT and both try to insert.
func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
def := s.defaultLayout(ctx)
def := FactoryDefaultLayout()
bytes, err := json.Marshal(def)
if err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err)

View File

@@ -22,18 +22,8 @@ func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
if def.Widgets[i].Key != k {
t.Errorf("widgets[%d].Key = %q; want %q", i, def.Widgets[i].Key, k)
}
// Slice C: some catalog entries default-hidden (pinned-projects,
// quick-actions) — opt-in via the picker. Factory visibility
// must match the catalog declaration so a user can re-enable a
// widget without going through the picker every time.
catalogDef, ok := LookupWidgetDef(k)
if !ok {
t.Errorf("widgets[%d] %q has no catalog def", i, k)
continue
}
if def.Widgets[i].Visible != catalogDef.DefaultVisible {
t.Errorf("widgets[%d] %q: factory Visible=%v; catalog DefaultVisible=%v",
i, k, def.Widgets[i].Visible, catalogDef.DefaultVisible)
if !def.Widgets[i].Visible {
t.Errorf("widgets[%d].Visible = false; factory default should be all-visible", i)
}
}
}
@@ -220,34 +210,6 @@ func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
}
}
func TestWidgetCatalog_SliceC_HasPinnedAndQuickActions(t *testing.T) {
// Slice C activated pinned-projects + quick-actions in the catalog
// AND in KnownWidgetKeys. Lock both via this test so a future
// change can't accidentally remove them without thinking about the
// firm-default migration story.
if _, ok := LookupWidgetDef(WidgetPinnedProjects); !ok {
t.Errorf("WidgetCatalog missing pinned-projects entry")
}
if _, ok := LookupWidgetDef(WidgetQuickActions); !ok {
t.Errorf("WidgetCatalog missing quick-actions entry")
}
var hasPinned, hasQuick bool
for _, k := range KnownWidgetKeys {
if k == WidgetPinnedProjects {
hasPinned = true
}
if k == WidgetQuickActions {
hasQuick = true
}
}
if !hasPinned {
t.Errorf("KnownWidgetKeys missing pinned-projects")
}
if !hasQuick {
t.Errorf("KnownWidgetKeys missing quick-actions")
}
}
func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
for _, k := range KnownWidgetKeys {

View File

@@ -14,7 +14,6 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -25,7 +24,6 @@ type DashboardService struct {
db *sqlx.DB
users *UserService
approvals *ApprovalService
pins *PinService
}
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
@@ -41,14 +39,6 @@ func (s *DashboardService) SetApprovalService(a *ApprovalService) {
s.approvals = a
}
// SetPinService wires the pinned-projects widget data source (Slice C).
// PinService pre-dates t-paliad-219 (mig 062/063) so no new schema is
// needed. Safe to leave nil — PinnedProjects then comes back empty and
// the widget renders its empty state.
func (s *DashboardService) SetPinService(p *PinService) {
s.pins = p
}
// DashboardData is the full payload returned to the frontend.
type DashboardData struct {
User *DashboardUser `json:"user"`
@@ -59,23 +49,8 @@ type DashboardData struct {
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []ActivityEntry `json:"recent_activity"`
InboxSummary InboxSummary `json:"inbox_summary"`
PinnedProjects []PinnedProjectRef `json:"pinned_projects"`
}
// PinnedProjectRef is one row in DashboardData.PinnedProjects — the
// minimum needed to render a clickable entry in the pinned-projects
// widget. Order matches PinService.ListPinned (pinned_at DESC).
type PinnedProjectRef struct {
ProjectID uuid.UUID `json:"project_id" db:"id"`
ProjectTitle string `json:"project_title" db:"title"`
ProjectRef string `json:"project_reference" db:"reference"`
}
// PinnedProjectsCap caps the pinned-projects preview list. The widget
// count setting tops out at 20; we fetch the cap once and let the
// client trim further per the user's setting.
const PinnedProjectsCap = 20
// InboxSummary feeds the inbox-approvals widget on the configurable
// dashboard (t-paliad-219). PendingCount is the precise number of
// approval requests that await this user's approval; Top is a small
@@ -200,7 +175,6 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
UpcomingDeadlines: []UpcomingDeadline{},
UpcomingAppointments: []UpcomingAppointment{},
RecentActivity: []ActivityEntry{},
PinnedProjects: []PinnedProjectRef{},
}
if user == nil {
return data, nil
@@ -239,77 +213,11 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
if err := s.loadInboxSummary(ctx, data, user); err != nil {
return nil, err
}
if err := s.loadPinnedProjects(ctx, data, user); err != nil {
return nil, err
}
annotateUrgency(data.UpcomingDeadlines, now)
return data, nil
}
// loadPinnedProjects populates DashboardData.PinnedProjects (Slice C).
// Reads PinService.ListPinned for ordering, then materialises titles +
// references via a single visibility-filtered SELECT. When PinService
// is unwired or the user has no pins, the field comes back empty and
// the widget renders its empty state.
func (s *DashboardService) loadPinnedProjects(ctx context.Context, data *DashboardData, user *models.User) error {
if s.pins == nil {
return nil
}
ids, err := s.pins.ListPinned(ctx, user.ID)
if err != nil {
return fmt.Errorf("dashboard pinned ids: %w", err)
}
if len(ids) == 0 {
return nil
}
if len(ids) > PinnedProjectsCap {
ids = ids[:PinnedProjectsCap]
}
query := `
SELECT p.id,
p.title,
COALESCE(p.reference, '') AS reference
FROM paliad.projects p
WHERE p.id = ANY($2::uuid[])
AND ` + visibilityPredicatePositional("p", 1)
rows := []PinnedProjectRef{}
if err := s.db.SelectContext(ctx, &rows, query, user.ID, idsToArray(ids)); err != nil {
return fmt.Errorf("dashboard pinned projects: %w", err)
}
// Restore pinned-at order: PinService.ListPinned ordered DESC; the
// SELECT above doesn't preserve that. Build a position map and
// re-sort.
pos := make(map[uuid.UUID]int, len(ids))
for i, id := range ids {
pos[id] = i
}
out := make([]PinnedProjectRef, 0, len(rows))
for _, r := range rows {
out = append(out, r)
}
// Tiny n (cap=20); a simple insertion-style swap is enough.
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if pos[out[j].ProjectID] < pos[out[i].ProjectID] {
out[i], out[j] = out[j], out[i]
}
}
}
data.PinnedProjects = out
return nil
}
// idsToArray flips a []uuid.UUID into a Postgres uuid[] payload via
// pq.Array — mirrors the pattern in rule_editor_orphans.go.
func idsToArray(ids []uuid.UUID) interface{} {
out := make([]string, len(ids))
for i, id := range ids {
out[i] = id.String()
}
return pq.Array(out)
}
// loadSummary fills DeadlineSummary + AppointmentSummary + MatterSummary.
//
// Bucket math comes from computeDeadlineBucketBounds (deadline_service.go) so

View File

@@ -12,8 +12,6 @@ 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:
@@ -100,30 +98,6 @@ 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,17 +41,11 @@ 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,
}
@@ -426,10 +420,6 @@ 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,8 +21,6 @@ func EmailTemplateVariables(key string) []EmailTemplateVariable {
switch key {
case EmailTemplateKeyInvitation:
return invitationVariables
case EmailTemplateKeyAddUserWelcome:
return addUserWelcomeVariables
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestVariables
case EmailTemplateKeyBase:
@@ -53,30 +51,6 @@ 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

@@ -1,106 +0,0 @@
package services
// FirmDashboardDefaultService manages paliad.firm_dashboard_default — the
// optional firm-wide dashboard layout that DashboardLayoutService prefers
// over the code-resident FactoryDefaultLayout when seeding a new user's
// row or resetting an existing layout.
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §8.2
// (firm-wide default, deferred to v1.1 — activated in Slice C).
//
// Single optional row identified by id=1. Get returns (spec, true, nil)
// when set, (zero, false, nil) when never set. Set overwrites; Clear
// deletes (so GetOrSeed reverts to FactoryDefaultLayout).
//
// The HTTP layer (handlers/firm_dashboard_default.go) enforces admin-only
// via auth.RequireAdmin. The service itself takes no admin parameter —
// it trusts its callers because the only writer is the admin handler;
// the read path is used by DashboardLayoutService on every seed/reset.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// FirmDashboardDefaultService manages paliad.firm_dashboard_default.
type FirmDashboardDefaultService struct {
db *sqlx.DB
}
// NewFirmDashboardDefaultService wires the service.
func NewFirmDashboardDefaultService(db *sqlx.DB) *FirmDashboardDefaultService {
return &FirmDashboardDefaultService{db: db}
}
// Get returns (spec, true, nil) if a firm default is set, (zero, false,
// nil) otherwise. SanitizeForRead is applied so callers always receive a
// version-coherent spec; if anything had to be dropped (e.g. an admin
// stashed a layout that references widgets we later removed), the
// cleanup is in-memory only and the next admin write will persist it.
func (s *FirmDashboardDefaultService) Get(ctx context.Context) (DashboardLayoutSpec, bool, error) {
var raw json.RawMessage
err := s.db.GetContext(ctx, &raw, `
SELECT layout_json
FROM paliad.firm_dashboard_default
WHERE id = 1
`)
if errors.Is(err, sql.ErrNoRows) {
return DashboardLayoutSpec{}, false, nil
}
if err != nil {
return DashboardLayoutSpec{}, false, fmt.Errorf("get firm dashboard default: %w", err)
}
var spec DashboardLayoutSpec
if err := json.Unmarshal(raw, &spec); err != nil {
// Stored row is unparseable — treat as missing so the seed path
// reverts to FactoryDefaultLayout rather than crash.
return DashboardLayoutSpec{}, false, nil
}
spec.SanitizeForRead()
return spec, true, nil
}
// Set persists the layout as the firm-wide default. Validates against the
// catalog so an admin can't seed a layout that violates the contract.
// updatedBy is recorded for audit; passing uuid.Nil clears the column.
func (s *FirmDashboardDefaultService) Set(ctx context.Context, spec DashboardLayoutSpec, updatedBy uuid.UUID) (DashboardLayoutSpec, error) {
if err := spec.Validate(); err != nil {
return DashboardLayoutSpec{}, err
}
bytes, err := json.Marshal(spec)
if err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("firm dashboard default marshal: %w", err)
}
var updaterArg interface{}
if updatedBy != uuid.Nil {
updaterArg = updatedBy
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.firm_dashboard_default (id, layout_json, updated_by, updated_at)
VALUES (1, $1, $2, now())
ON CONFLICT (id) DO UPDATE
SET layout_json = EXCLUDED.layout_json,
updated_by = EXCLUDED.updated_by,
updated_at = now()
`, json.RawMessage(bytes), updaterArg)
if err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("firm dashboard default upsert: %w", err)
}
return spec, nil
}
// Clear deletes the firm default so seeds/resets revert to FactoryDefault-
// Layout. Idempotent — clearing an already-absent row is a no-op.
func (s *FirmDashboardDefaultService) Clear(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM paliad.firm_dashboard_default WHERE id = 1`)
if err != nil {
return fmt.Errorf("firm dashboard default clear: %w", err)
}
return nil
}

View File

@@ -1,93 +0,0 @@
package services
// Live-DB tests for FirmDashboardDefaultService — gated on
// TEST_DATABASE_URL like the rest of the integration suite.
//
// These cover the round-trip (Set → Get → Clear → Get) and the
// SanitizeForRead behavior on read. Pure-function validation lives in
// dashboard_layout_spec_test.go.
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func openTestDBForFirmDefault(t *testing.T) *sqlx.DB {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping firm-dashboard-default live test")
}
db, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
return db
}
func TestFirmDashboardDefault_RoundTrip(t *testing.T) {
db := openTestDBForFirmDefault(t)
defer db.Close()
svc := NewFirmDashboardDefaultService(db)
ctx := context.Background()
// Start clean — prior tests may have left a row.
if err := svc.Clear(ctx); err != nil {
t.Fatalf("pre-clear: %v", err)
}
if _, ok, err := svc.Get(ctx); err != nil || ok {
t.Fatalf("Get after Clear: ok=%v err=%v; want ok=false err=nil", ok, err)
}
spec := FactoryDefaultLayout()
if _, err := svc.Set(ctx, spec, uuid.Nil); err != nil {
t.Fatalf("Set factory: %v", err)
}
got, ok, err := svc.Get(ctx)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !ok {
t.Fatal("Get: ok=false after Set; want true")
}
if len(got.Widgets) != len(spec.Widgets) {
t.Errorf("widget count mismatch: %d vs %d", len(got.Widgets), len(spec.Widgets))
}
if got.Version != spec.Version {
t.Errorf("version mismatch: %d vs %d", got.Version, spec.Version)
}
// Clear is idempotent.
if err := svc.Clear(ctx); err != nil {
t.Fatalf("clear after set: %v", err)
}
if err := svc.Clear(ctx); err != nil {
t.Fatalf("second clear: %v", err)
}
if _, ok, err := svc.Get(ctx); err != nil || ok {
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
}
}
func TestFirmDashboardDefault_RejectsInvalid(t *testing.T) {
db := openTestDBForFirmDefault(t)
defer db.Close()
svc := NewFirmDashboardDefaultService(db)
ctx := context.Background()
bad := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
}}
_, err := svc.Set(ctx, bad, uuid.Nil)
if err == nil {
t.Fatal("Set with invalid count: err=nil; want ErrInvalidInput")
}
}

View File

@@ -94,27 +94,10 @@ type UIDeadline struct {
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
type UIResponse struct {
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
// ProceedingNameEN carries the English label of the proceeding so
// the frontend can switch on lang. Empty when the proceeding has no
// English label populated; the frontend falls back to ProceedingName.
// Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf
// "Trigger event" label fell back to the DE proceedingName whenever
// the timeline had no root rule (e.g. for sub-track proceedings like
// upc.ccr.cfi that have no native rules).
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
// ContextualNote / ContextualNoteEN surface a banner above the
// timeline. Populated by sub-track routing (m/paliad#58): when the
// user picks a proceeding that is normally a sub-track of another
// proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
// with_ccr), the renderer routes to the parent's rules but keeps
// the user-picked code/name as the response identity and surfaces a
// note explaining the framing.
ContextualNote string `json:"contextualNote,omitempty"`
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -254,42 +237,6 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
}
// Sub-track routing (m/paliad#58). When the user picks a proceeding
// that has no native rules and is normally a sub-track of another
// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
// rule lookup to the parent and merge the default flags into the
// user's flag set. The response identity (Code/Name/NameEN) stays
// on the user-picked proceeding so the page header still reads
// "Counterclaim for Revocation", but the timeline body is the
// parent's full flow with the sub-track flag enabled. A note
// surfaces the framing.
var pickedProceeding = pt
var subTrackNote SubTrackRouting
var hasSubTrackNote bool
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
subTrackNote = route
hasSubTrackNote = true
// Re-resolve to the parent proceeding for rule lookup.
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, route.ParentCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType)
}
if err != nil {
return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err)
}
// Merge default flags into the user's flag set so the gated
// rules render. User-supplied flags win on conflict (they're
// already in flagSet); default flags only add what's missing.
for _, f := range route.DefaultFlags {
if _, exists := flagSet[f]; !exists {
flagSet[f] = struct{}{}
}
}
}
// Resolve (country, regime) for non-working-day adjustment. Court wins
// when supplied; otherwise default by proceeding regime. UPC proceedings
// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
@@ -597,18 +544,12 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
deadlines = append(deadlines, d)
}
resp := &UIResponse{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN
}
return resp, nil
return &UIResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}, nil
}
// ErrUnknownRule is returned when CalculateRule can't resolve the

View File

@@ -173,53 +173,6 @@ 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

@@ -132,60 +132,8 @@ func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristen
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
// weiter." in the UI.
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
if route, ok := SubTrackRoutings[code]; ok {
return route.ParentCode, route.DefaultFlags, true
if code == CodeUPCCounterclaim {
return CodeUPCInfringement, []string{"with_ccr"}, true
}
return code, nil, false
}
// SubTrackRouting describes a proceeding type that has no native rules
// of its own and is normally rendered inside a parent proceeding's flow
// with one or more condition flags enabled. The Procedure Roadmap
// (verfahrensablauf) routes calc requests for these codes to the parent
// proceeding + default flags, but preserves the user-picked code/name
// in the response identity and surfaces a contextual note explaining
// the framing — see m/paliad#58 and the design doc cited above.
//
// Adding a new sub-track is a data-only change here: extend
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
// renderer picks it up automatically. The note copy lives in this file
// because it's semantic to the routing, not UI chrome.
type SubTrackRouting struct {
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
Code string
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
ParentCode string
// DefaultFlags are merged into the user's flag set so the
// gated rules render. Order is preserved.
DefaultFlags []string
// NoteDE / NoteEN are the contextual banner above the timeline,
// explaining that the proceeding type is normally a sub-track.
// Plain text — the frontend renders them as a banner.
NoteDE string
NoteEN string
}
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
// The pattern generalises to other "sub-track" proceeding types (e.g.
// R.30 application to amend the patent as a standalone roadmap, R.46
// preliminary objection) once they have a proceeding-type code of their
// own. New entries here are picked up by the spawn-as-standalone
// renderer in FristenrechnerService.Calculate without further wiring.
var SubTrackRoutings = map[string]SubTrackRouting{
CodeUPCCounterclaim: {
Code: CodeUPCCounterclaim,
ParentCode: CodeUPCInfringement,
DefaultFlags: []string{"with_ccr"},
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
},
}
// LookupSubTrackRouting returns the sub-track routing for a proceeding
// code, or (zero, false) if the code is not a sub-track. Used by the
// fristenrechner Calculate path to spawn the parent flow with the sub-
// track's default flags.
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
r, ok := SubTrackRoutings[code]
return r, ok
}

View File

@@ -81,43 +81,3 @@ func TestResolveCounterclaimRouting(t *testing.T) {
}
})
}
// TestSubTrackRoutings asserts the registry shape m/paliad#58 depends
// on: every entry's Code matches its map key, has a non-empty
// ParentCode + DefaultFlags + bilingual notes. Drift here silently
// breaks the spawn-as-standalone renderer (a CCR pick would 404 or
// render an empty timeline), so we pin the contract.
func TestSubTrackRoutings(t *testing.T) {
if len(SubTrackRoutings) == 0 {
t.Fatal("SubTrackRoutings is empty — at minimum upc.ccr.cfi must be registered")
}
for key, route := range SubTrackRoutings {
if route.Code != key {
t.Errorf("SubTrackRoutings[%q].Code = %q, want %q (key/value mismatch)", key, route.Code, key)
}
if route.ParentCode == "" {
t.Errorf("SubTrackRoutings[%q] has empty ParentCode", key)
}
if len(route.DefaultFlags) == 0 {
t.Errorf("SubTrackRoutings[%q] has no DefaultFlags — sub-track routing without flags is a no-op", key)
}
if route.NoteDE == "" || route.NoteEN == "" {
t.Errorf("SubTrackRoutings[%q] missing bilingual note: DE=%q EN=%q", key, route.NoteDE, route.NoteEN)
}
}
// CCR is the canonical entry — assert its exact shape so a future
// rename doesn't silently change semantics.
ccr, ok := LookupSubTrackRouting(CodeUPCCounterclaim)
if !ok {
t.Fatal("LookupSubTrackRouting(upc.ccr.cfi) returned ok=false; entry must be registered")
}
if ccr.ParentCode != CodeUPCInfringement {
t.Errorf("CCR.ParentCode = %q, want %q", ccr.ParentCode, CodeUPCInfringement)
}
if !reflect.DeepEqual(ccr.DefaultFlags, []string{"with_ccr"}) {
t.Errorf("CCR.DefaultFlags = %v, want [with_ccr]", ccr.DefaultFlags)
}
if _, miss := LookupSubTrackRouting(CodeUPCInfringement); miss {
t.Error("LookupSubTrackRouting(upc.inf.cfi) returned ok=true; non-sub-track codes must miss")
}
}

View File

@@ -1,242 +0,0 @@
// 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

@@ -1,154 +0,0 @@
// 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

@@ -1,68 +0,0 @@
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,8 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/mail"
"strings"
"time"
@@ -58,18 +56,8 @@ 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
supabase *SupabaseAdminClient
mail *MailService
baseURL string
db *sqlx.DB
}
// NewUserService wires the service to the pool.
@@ -77,17 +65,6 @@ 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,
@@ -607,193 +584,6 @@ 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

@@ -31,15 +31,10 @@ const (
WidgetRecentActivity WidgetKey = "recent-activity"
WidgetInboxApprovals WidgetKey = "inbox-approvals"
WidgetPinnedProjects WidgetKey = "pinned-projects"
WidgetQuickActions WidgetKey = "quick-actions"
)
// KnownWidgetKeys is the canonical order used when seeding the factory
// default layout. New entries land at the bottom by default.
//
// Slice C activated WidgetPinnedProjects (reusing the pin-machinery
// PinService that pre-dates t-paliad-219) and added WidgetQuickActions
// (pure UI; no backend data path) per m's brief on catalog expansion.
var KnownWidgetKeys = []WidgetKey{
WidgetDeadlineSummary,
WidgetMatterSummary,
@@ -48,8 +43,9 @@ var KnownWidgetKeys = []WidgetKey{
WidgetInlineAgenda,
WidgetRecentActivity,
WidgetInboxApprovals,
WidgetPinnedProjects,
WidgetQuickActions,
// WidgetPinnedProjects ships in Slice C (catalog expansion) — not in
// the Slice A1 baseline. Keep the const above for forward-compat;
// omit from KnownWidgetKeys until the widget module lands.
}
// WidgetSettingsSchema declares which knobs a widget exposes. nil = no
@@ -209,34 +205,6 @@ func WidgetCatalog() []WidgetDef {
CountOptions: inboxCounts,
},
},
// Slice C: pinned-projects rides on the pre-existing PinService
// (paliad.user_pinned_projects, mig 062/063 — pre-dates this
// task). DefaultVisible=false so existing users don't get a new
// widget injected unannounced; they opt in via the picker.
{
Key: WidgetPinnedProjects,
TitleDE: "Angepinnte Akten",
TitleEN: "Pinned matters",
DescriptionDE: "Schneller Zugriff auf deine angepinnten Akten.",
DescriptionEN: "Quick access to your pinned matters.",
DefaultVisible: false,
DefaultCount: &tenDefault,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
CountAllowsAll: true,
},
},
// Slice C: quick-actions is pure UI — no backend payload, no
// settings. Renders 3 affordances ("+ Akte", "+ Frist",
// "+ Termin") that link to the existing create surfaces.
{
Key: WidgetQuickActions,
TitleDE: "Schnellzugriff",
TitleEN: "Quick actions",
DescriptionDE: "Direkte Buttons für neue Akten, Fristen und Termine.",
DescriptionEN: "Direct buttons for new matters, deadlines, and appointments.",
DefaultVisible: false,
},
}
}

View File

@@ -1,12 +0,0 @@
{{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

@@ -1,12 +0,0 @@
{{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}}