Compare commits
20 Commits
mai/gauss/
...
mai/dirac/
| Author | SHA1 | Date | |
|---|---|---|---|
| e56cb3b210 | |||
| fffddcc71a | |||
| b850eb755c | |||
| a93277a072 | |||
| c3cd51eb85 | |||
| 6b634207c2 | |||
| 794617cbfd | |||
| b418705775 | |||
| 7a1fd81d23 | |||
| a4e2f3526d | |||
| 1c8cdd3079 | |||
| 82ecbe3b8e | |||
| badbffa6e0 | |||
| 0f98d2cd39 | |||
| d0f732d0ec | |||
| e83b150eda | |||
| 2320cb765d | |||
| 668558380d | |||
| 9dd47a0591 | |||
| 3d3a4fa36d |
@@ -128,6 +128,20 @@ func main() {
|
||||
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
||||
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
|
||||
|
||||
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
|
||||
// new "Konto direkt anlegen" path on /admin/team. The key is
|
||||
// optional: when unset the client still wires (so dependents
|
||||
// don't panic) but every call short-circuits with
|
||||
// ErrSupabaseAdminUnavailable so the rest of the server stays
|
||||
// runnable.
|
||||
supabaseAdminClient := services.LoadSupabaseAdminClient()
|
||||
if supabaseAdminClient.Enabled() {
|
||||
log.Println("supabase admin API configured — /admin/team Add-User path active")
|
||||
} else {
|
||||
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
|
||||
}
|
||||
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
|
||||
|
||||
// Wire EmailTemplateService onto the MailService so DB-backed admin
|
||||
// edits propagate without a process restart. The constructor is split
|
||||
// from MailService creation because the DB pool isn't available yet
|
||||
@@ -137,6 +151,11 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
@@ -165,7 +184,11 @@ func main() {
|
||||
EventType: eventTypeSvc,
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
|
||||
ChecklistCatalog: checklistCatalogSvc,
|
||||
ChecklistTemplate: checklistTemplateSvc,
|
||||
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
||||
|
||||
448
docs/design-calendar-view-align-2026-05-20.md
Normal file
448
docs/design-calendar-view-align-2026-05-20.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# Design: Align calendar-view rendering between Events/Termine and Custom Views
|
||||
|
||||
**Task:** t-paliad-224 — m/paliad#55
|
||||
**Author:** bohr (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Status:** ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch.
|
||||
**Branch:** `mai/bohr/calendar-view-align`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (verified against live source 2026-05-20)
|
||||
|
||||
m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two:
|
||||
|
||||
| | A — Events tab | B — Standalone | C — Custom Views |
|
||||
|---|---|---|---|
|
||||
| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` |
|
||||
| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) |
|
||||
| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) |
|
||||
| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) |
|
||||
| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage` — `internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` |
|
||||
|
||||
**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context.
|
||||
|
||||
The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. m's intent (as I read it)
|
||||
|
||||
> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!"
|
||||
|
||||
The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is:
|
||||
|
||||
1. **One calendar component**, mounted from both the events-page surface and the custom-views surface.
|
||||
2. **Identical visual output** when the same items land in either surface.
|
||||
3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go.
|
||||
4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body.
|
||||
|
||||
The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3.
|
||||
|
||||
---
|
||||
|
||||
## 2. What actually diverges today
|
||||
|
||||
Side-by-side after reading all three implementations (cited line numbers above):
|
||||
|
||||
| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) |
|
||||
|---|---|---|---|
|
||||
| Views offered | month only | month only | month + week + day |
|
||||
| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` |
|
||||
| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" |
|
||||
| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded** — `views-calendar-pill--{deadline|appointment|project_event|approval_request}` |
|
||||
| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) |
|
||||
| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal |
|
||||
| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) |
|
||||
| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view |
|
||||
| Toolbar | inline ‹ month-label › + Heute button | identical | view-switcher chips (M/W/D) + ‹ range-label › + (in day/week) "Zurück zum Monat" link |
|
||||
| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) |
|
||||
| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) |
|
||||
| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) |
|
||||
| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) |
|
||||
| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup |
|
||||
| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` |
|
||||
|
||||
The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem.
|
||||
|
||||
CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away.
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended design (TL;DR)
|
||||
|
||||
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|
||||
|---|---|---|
|
||||
| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. |
|
||||
| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. |
|
||||
| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. |
|
||||
| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type` → `kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. |
|
||||
| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. |
|
||||
| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. |
|
||||
| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. |
|
||||
| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. |
|
||||
|
||||
**Net code change (estimated by file):**
|
||||
|
||||
- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines.
|
||||
- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state).
|
||||
- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block).
|
||||
- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types).
|
||||
- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim.
|
||||
- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`).
|
||||
|
||||
Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture sketch
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ frontend/src/client/ │
|
||||
│ calendar/ │
|
||||
│ mount-calendar.ts ★ │ ← new shared module
|
||||
│ types.ts (CalendarItem)│
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌────────────────────────┼─────────────────────────┐
|
||||
│ │ │
|
||||
client/events.ts (Kalender tab) client/views/ │
|
||||
│ shape-calendar.ts │
|
||||
│ (thin wrapper) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ client/views.ts │
|
||||
│ paintRows(…, "calendar") │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
Data flows:
|
||||
A: /events → fetch /api/events?type=…&status=… → EventListItem[]
|
||||
→ toCalendarItem(items) → CalendarItem[]
|
||||
→ mountCalendar(host, items, opts)
|
||||
|
||||
C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[]
|
||||
→ toCalendarItem(rows) (noop-ish: rename ‘type’→‘kind’ 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).
|
||||
|
||||
---
|
||||
918
docs/design-user-checklists-2026-05-20.md
Normal file
918
docs/design-user-checklists-2026-05-20.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# User-authored checklists: authoring, sharing, admin-promotion
|
||||
|
||||
**Task:** t-paliad-225 — Gitea m/paliad#61
|
||||
**Inventor:** dirac, 2026-05-20
|
||||
**Branch:** `mai/dirac/user-checklists`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
Paliad ships a curated catalog of UPC / DE / EPA checklists today
|
||||
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
|
||||
on Akten and check items off; per-instance state lives in
|
||||
`paliad.checklist_instances` and is gated by the parent project's
|
||||
team-based visibility.
|
||||
|
||||
m wants three new capabilities (m 2026-05-20 14:14):
|
||||
|
||||
1. **User-authored templates** — any non-`global_admin` can create a
|
||||
checklist template they own (title, sections, items, references).
|
||||
2. **Sharing** — author shares with specific colleagues, an Office, a
|
||||
Dezernat (partner-unit), a project team, or the whole firm.
|
||||
3. **Admin promotion to global** — `global_admin` promotes an authored
|
||||
template into the firm-wide catalog so it appears alongside the
|
||||
curated UPC/DE/EPA templates for every user.
|
||||
|
||||
This design covers all three across three sequential slices.
|
||||
|
||||
## 2. Premises verified live (load-bearing findings)
|
||||
|
||||
The Gitea issue body says "Add `owner_id uuid NULL` to
|
||||
`paliad.checklists`". That table **does not exist**. Verifying against
|
||||
the live DB and the code corrected several premises:
|
||||
|
||||
- **`paliad.checklists` does NOT exist as a DB table.** Templates today
|
||||
are pure Go data in `internal/checklists/templates.go` (6 entries,
|
||||
~310 lines), served by `internal/handlers/checklists.go` via
|
||||
`checklists.Summaries()` and `checklists.Find(slug)`. The DB has
|
||||
`paliad.checklist_instances` (per-user state) and
|
||||
`paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
|
||||
design has to introduce `paliad.checklists` from scratch.
|
||||
|
||||
- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
|
||||
validity is enforced in `ChecklistInstanceService.Create` against the
|
||||
static Go registry. This is what lets the design keep the static
|
||||
catalog as one source of truth and add the DB catalog as a parallel
|
||||
source: instance creation just resolves the slug against the merged
|
||||
view and snapshots the template body.
|
||||
|
||||
- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
|
||||
on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
|
||||
109 user_dashboard_layouts, 110 project_type_other, 111
|
||||
project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
|
||||
today). At inventor time the next free slot is **112**. The coder
|
||||
MUST re-verify with `ls internal/db/migrations/ | tail` at shift
|
||||
start — the slot can drift if other branches merge first.
|
||||
|
||||
- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
|
||||
migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
|
||||
STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
|
||||
branches on global_admin shortcut + project_teams responsibility =
|
||||
'admin'. **Used by this design** to gate the "Make global" button (we
|
||||
reuse the global_admin shortcut, not the project-admin branch — see
|
||||
§4.4) and as the precedent for any new STABLE SECURITY DEFINER
|
||||
predicates we add.
|
||||
|
||||
- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
|
||||
Columns: `event_type` (free-text), `actor_id`, `actor_email`,
|
||||
`scope` ∈ {org, project, personal}, `scope_root uuid`,
|
||||
`metadata jsonb`. RLS: self-read for the actor +
|
||||
global_admin read-all. **Pattern to follow:** insert event row at
|
||||
state transition (see `ExportService.WriteAuditRow` in
|
||||
`internal/services/export_service.go:1120` for the canonical shape).
|
||||
|
||||
- **`paliad.project_events`** is the project-timeline audit sink and is
|
||||
already wired for checklist instance lifecycle events
|
||||
(`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
|
||||
`_deleted`). We do NOT need to invent a new event_type for instance
|
||||
events; we'll add a few `_snapshot_taken` / template-level events to
|
||||
`system_audit_log` and keep instance events on `project_events`.
|
||||
|
||||
- **`paliad.users.office`** is `text` (CHECK against the office key
|
||||
list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
|
||||
hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
|
||||
have `additional_offices text[]`. Both are first-class columns; no
|
||||
separate `offices` table.
|
||||
|
||||
- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
|
||||
timestamps) is the Dezernat / practice-group table. Membership lives
|
||||
in `paliad.partner_unit_members`. Projects attach via
|
||||
`paliad.project_partner_units` (with derivation flags). All three
|
||||
are referenceable from a share recipient.
|
||||
|
||||
- **`paliad.users.global_role`** is `text`; values include
|
||||
`'global_admin'`. Used for the firm-wide promote/demote authority.
|
||||
|
||||
- **`paliad.project_teams`** (mig 111 just added) carries
|
||||
`responsibility` ∈ {admin, lead, member, observer, external}. We
|
||||
reuse `can_see_project` (visibility) for share-to-project recipients,
|
||||
NOT `effective_project_admin`. The semantic of "share with a project
|
||||
team" is "anyone on the matter sees it", not "anyone who can edit
|
||||
membership sees it".
|
||||
|
||||
- **No precedent for entity-level sharing in paliad.** The personal-
|
||||
sidecar tables (`user_views`, `user_dashboard_layouts`,
|
||||
`user_pinned_projects`, `user_card_layouts`) are owner-only with no
|
||||
share columns. Existing visibility predicates
|
||||
(`paliad.can_see_project`) walk the project tree, not arbitrary
|
||||
entities. This design introduces the first multi-axis share pattern
|
||||
in the codebase (§3.2).
|
||||
|
||||
## 3. Architecture: hybrid templates + share table
|
||||
|
||||
### 3.1 Two template sources, one read layer
|
||||
|
||||
**KEEP** the static Go template registry as the firm's curated catalog.
|
||||
It's version-controlled, code-reviewed, immutable at runtime, and the
|
||||
right substrate for legally-curated content (RoP citations, EPC rule
|
||||
references). Migrating those into DB rows would lose the git review
|
||||
trail for content that requires lawyer eyes.
|
||||
|
||||
**ADD** `paliad.checklists` as the DB catalog for user-authored content.
|
||||
Same Template shape (slug, titles, regime, court, groups[], items[])
|
||||
but stored as JSONB so the schema doesn't have to chase content
|
||||
evolution.
|
||||
|
||||
A `ChecklistCatalogService` unifies the two at read time:
|
||||
- `ListVisible(user)` → static templates ∪ DB rows the user can see
|
||||
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
|
||||
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
|
||||
rejected if they collide with a static slug).
|
||||
|
||||
Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
|
||||
their JSON shape — they just delegate to the catalog service instead of
|
||||
the bare static registry.
|
||||
|
||||
### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred
|
||||
|
||||
The task brief asks for a "modular / abstract" solution. I considered a
|
||||
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
|
||||
recipient_*)` table that could later carry shares for views, dashboards,
|
||||
saved searches, project templates, etc.
|
||||
|
||||
**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
|
||||
v1.** Reasons:
|
||||
|
||||
1. There is NO second entity in paliad that requests sharing today —
|
||||
`user_views`, `user_dashboard_layouts`, `user_card_layouts`,
|
||||
`user_pinned_projects` are all explicitly owner-only by design (see
|
||||
migration comments). The "future reuse" is hypothetical.
|
||||
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
|
||||
needs its own deletion trigger. That complexity is real, the
|
||||
reusability gain is not.
|
||||
3. The CORRECT abstraction emerges by extracting *after* the second use
|
||||
case shows up. Right now we don't know whether dashboards want the
|
||||
same recipient axes (user / office / partner-unit / project) or a
|
||||
different set (e.g. dashboards probably want "everyone on a project"
|
||||
not "the whole firm").
|
||||
|
||||
The design IS modular in the sense that the recipient resolution logic
|
||||
(below) is centralized in one SQL predicate (§4.3) which a future
|
||||
polymorphic refactor can lift verbatim.
|
||||
|
||||
If the second entity asks for sharing within ~3 months, refactor to
|
||||
`paliad.entity_shares` as a single-mig follow-up. Until then,
|
||||
`paliad.checklist_shares` keeps the schema honest.
|
||||
|
||||
### 3.3 Visibility states
|
||||
|
||||
`paliad.checklists.visibility text` (CHECK enum):
|
||||
|
||||
| state | who sees | who edits |
|
||||
|-----------|----------------------------------------------------|---------------------|
|
||||
| `private` | owner only | owner |
|
||||
| `shared` | owner + explicit recipients in checklist_shares | owner |
|
||||
| `firm` | owner + every authenticated paliad user | owner |
|
||||
| `global` | owner + every authenticated paliad user + catalog | owner + global_admin|
|
||||
|
||||
`firm` vs `global` distinction:
|
||||
- `firm` = author self-published. Author can flip back to private/shared
|
||||
any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
|
||||
in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
|
||||
- `global` = admin-promoted into the firm catalog. Appears in the main
|
||||
Vorlagen tab alongside the static templates. Author retains edit
|
||||
authority by default; only `global_admin` can demote.
|
||||
|
||||
Demotion target: `global → firm` (preserves visibility for users who
|
||||
already started instances). Author can subsequently narrow further.
|
||||
|
||||
### 3.4 Template snapshot on instance create
|
||||
|
||||
m's brief calls this out as a design decision: when an author edits a
|
||||
template, do existing instances pick up the changes (propagate) or stay
|
||||
on the version they were created from (snapshot)?
|
||||
|
||||
**Pick: snapshot.** Inventor pick (R). Rationale:
|
||||
|
||||
1. **Data integrity.** Instances are working artefacts. A user halfway
|
||||
through a Klageerwiderung instance shouldn't have items disappear or
|
||||
reorder under them because the author edited the template.
|
||||
2. **Audit story.** The completed instance shows exactly what the
|
||||
author saw when they started. Reconstruction without git-blame on
|
||||
the template.
|
||||
3. **Visibility narrowing safe by construction.** If author unshares
|
||||
from a colleague who already has an instance, the instance survives
|
||||
because the snapshot is local.
|
||||
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
|
||||
exceed a few per user per template. Even 10× the row size of today
|
||||
is fine.
|
||||
|
||||
Schema cost: one nullable `template_snapshot jsonb` column on
|
||||
`paliad.checklist_instances`. Backfilled lazily — existing instances
|
||||
keep `NULL`, service falls back to looking the slug up in the catalog;
|
||||
new instances always get a snapshot. Slice C can backfill the column
|
||||
for already-existing rows via a one-off `UPDATE` if we want strict
|
||||
consistency.
|
||||
|
||||
## 4. Schema (migration 112 — verify slot at coder shift)
|
||||
|
||||
Single migration file `internal/db/migrations/112_user_checklists.up.sql`
|
||||
+ matching `.down.sql`. Idempotent throughout
|
||||
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).
|
||||
|
||||
> Slot caveat: at design time, latest disk = 111, live tracker = 106
|
||||
> (mig 107-111 pending deploy). Coder MUST re-verify
|
||||
> `ls internal/db/migrations/ | tail` at shift start. If a higher
|
||||
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
|
||||
> 112), bump to the next free slot.
|
||||
|
||||
### 4.1 `paliad.checklists` — authored template catalog
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.checklists (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
-- Authoring metadata
|
||||
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
regime text NOT NULL DEFAULT 'OTHER', -- UPC | DE | EPA | OTHER
|
||||
court text NOT NULL DEFAULT '',
|
||||
reference text NOT NULL DEFAULT '',
|
||||
deadline text NOT NULL DEFAULT '',
|
||||
lang text NOT NULL DEFAULT 'de', -- 'de' | 'en' — author's primary language
|
||||
-- Body
|
||||
body jsonb NOT NULL, -- { groups: [{ title, items: [{ label, note, rule }] }] }
|
||||
-- Lifecycle
|
||||
visibility text NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
|
||||
promoted_at timestamptz, -- set on transition to 'global'
|
||||
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- Timestamps
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX checklists_owner_idx ON paliad.checklists (owner_id);
|
||||
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
|
||||
CREATE INDEX checklists_regime_idx ON paliad.checklists (regime);
|
||||
```
|
||||
|
||||
**Slug-collision safety net:** application layer validates that the
|
||||
chosen slug doesn't collide with a static template slug. The static
|
||||
list is loaded into a `map[string]bool` at boot. New authored slugs
|
||||
auto-prefixed with `u-` so collisions with static slugs are structurally
|
||||
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).
|
||||
|
||||
### 4.2 `paliad.checklist_shares` — explicit grants
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.checklist_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
|
||||
recipient_kind text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
|
||||
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
recipient_office text,
|
||||
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- XOR check: exactly one recipient_* column populated per kind
|
||||
CONSTRAINT checklist_shares_recipient_xor CHECK (
|
||||
(recipient_kind = 'user' AND recipient_user_id IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'office' AND recipient_office IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'project' AND recipient_project_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Avoid duplicates per recipient
|
||||
CREATE UNIQUE INDEX checklist_shares_user_uniq ON paliad.checklist_shares (checklist_id, recipient_user_id) WHERE recipient_kind = 'user';
|
||||
CREATE UNIQUE INDEX checklist_shares_office_uniq ON paliad.checklist_shares (checklist_id, recipient_office) WHERE recipient_kind = 'office';
|
||||
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
|
||||
CREATE UNIQUE INDEX checklist_shares_project_uniq ON paliad.checklist_shares (checklist_id, recipient_project_id) WHERE recipient_kind = 'project';
|
||||
|
||||
-- Hot-path index for the visibility predicate
|
||||
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
|
||||
```
|
||||
|
||||
### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner can always see
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.owner_id = _user_id
|
||||
)
|
||||
-- 'firm' / 'global' visible to all authenticated users
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.visibility IN ('firm', 'global')
|
||||
)
|
||||
-- Explicit share: user
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = _user_id
|
||||
)
|
||||
-- Explicit share: office (matches user.office OR additional_offices)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
-- Explicit share: partner_unit (caller is a member)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
-- Explicit share: project (caller can see the project via existing predicate)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'project'
|
||||
AND paliad.can_see_project(s.recipient_project_id) -- reuses ltree walk
|
||||
);
|
||||
$$;
|
||||
```
|
||||
|
||||
> Note on `can_see_project` self-reference: that function reads
|
||||
> `auth.uid()` internally — when called from inside another SECURITY
|
||||
> DEFINER body it picks up the caller's uid via search_path inheritance
|
||||
> (same pattern as `effective_project_admin` reuse in mig 111).
|
||||
|
||||
### 4.4 RLS on `paliad.checklists`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: owner OR visible via can_see_checklist
|
||||
CREATE POLICY checklists_select
|
||||
ON paliad.checklists FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_checklist(auth.uid(), id));
|
||||
|
||||
-- INSERT: caller can only create templates owned by themselves
|
||||
CREATE POLICY checklists_insert
|
||||
ON paliad.checklists FOR INSERT TO authenticated
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
|
||||
CREATE POLICY checklists_update
|
||||
ON paliad.checklists FOR UPDATE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin
|
||||
CREATE POLICY checklists_delete
|
||||
ON paliad.checklists FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.5 RLS on `paliad.checklist_shares`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
|
||||
CREATE POLICY checklist_shares_select
|
||||
ON paliad.checklist_shares FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- INSERT: only the checklist owner can grant
|
||||
CREATE POLICY checklist_shares_insert
|
||||
ON paliad.checklist_shares FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
AND granted_by = auth.uid()
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
|
||||
CREATE POLICY checklist_shares_delete
|
||||
ON paliad.checklist_shares FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
```
|
||||
|
||||
### 4.6 `paliad.checklist_instances.template_snapshot jsonb`
|
||||
|
||||
```sql
|
||||
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
|
||||
```
|
||||
|
||||
Existing RLS on `checklist_instances` untouched.
|
||||
|
||||
## 5. Service layer
|
||||
|
||||
### 5.1 `internal/services/checklist_catalog_service.go` (new)
|
||||
|
||||
Unified read facade over static + DB templates.
|
||||
|
||||
```go
|
||||
type ChecklistCatalogService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
type CatalogEntry struct {
|
||||
Slug string // matches checklists.Template.Slug or paliad.checklists.slug
|
||||
Origin string // "static" | "authored"
|
||||
OwnerID *uuid.UUID // nil for static
|
||||
OwnerName string // empty for static
|
||||
Visibility string // "static" | "private" | "shared" | "firm" | "global"
|
||||
Template checklists.Template
|
||||
}
|
||||
|
||||
// ListVisible returns every catalog entry the caller can see.
|
||||
// Static entries are always returned. DB entries pass through RLS.
|
||||
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)
|
||||
|
||||
// Find returns one entry by slug (static lookup first, then DB).
|
||||
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)
|
||||
|
||||
// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
|
||||
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
|
||||
```
|
||||
|
||||
### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)
|
||||
|
||||
CRUD on `paliad.checklists`.
|
||||
|
||||
```go
|
||||
type ChecklistTemplateService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
type CreateTemplateInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Regime string
|
||||
Court string
|
||||
Reference string
|
||||
Deadline string
|
||||
Lang string
|
||||
Body checklists.Template // unmarshalled to body jsonb minus slug/titles/etc
|
||||
}
|
||||
|
||||
func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
|
||||
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
|
||||
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
|
||||
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error // private/firm only
|
||||
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
|
||||
```
|
||||
|
||||
Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
|
||||
suffix (collision retry up to 3x). Validator enforces
|
||||
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
|
||||
`internal/checklists/checklists.go` Templates rejected at write time.
|
||||
|
||||
### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)
|
||||
|
||||
```go
|
||||
type ChecklistShareService struct { db *sqlx.DB }
|
||||
|
||||
type ShareGrantInput struct {
|
||||
RecipientKind string
|
||||
UserID *uuid.UUID
|
||||
Office string
|
||||
PartnerUnitID *uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
}
|
||||
|
||||
func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
|
||||
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
|
||||
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
|
||||
```
|
||||
|
||||
### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)
|
||||
|
||||
`global_admin`-only operations.
|
||||
|
||||
```go
|
||||
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }
|
||||
|
||||
func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
|
||||
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
|
||||
```
|
||||
|
||||
Promote: assert caller.global_role = 'global_admin' → UPDATE visibility =
|
||||
'global', promoted_at = now(), promoted_by = caller → audit row
|
||||
`event_type='checklist.promoted_global'`.
|
||||
|
||||
Demote: assert caller is global_admin → UPDATE visibility = target
|
||||
(default 'firm') → audit row `event_type='checklist.demoted'`.
|
||||
|
||||
### 5.5 Wire instance create to take snapshot
|
||||
|
||||
`ChecklistInstanceService.Create` extends to capture
|
||||
`template_snapshot` at insert time via
|
||||
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
|
||||
(NULL snapshot, fallback path in read layer).
|
||||
|
||||
### 5.6 Endpoints
|
||||
|
||||
| Method | Path | Slice | Purpose |
|
||||
|--------|------|-------|---------|
|
||||
| `GET` | `/api/checklists` | (existing)| Merged catalog list (static + visible DB) |
|
||||
| `GET` | `/api/checklists/{slug}` | (existing)| Single template (static or DB) |
|
||||
| `POST` | `/api/checklists/templates` | A | Create authored template |
|
||||
| `GET` | `/api/checklists/templates/mine` | A | List own authored templates |
|
||||
| `PATCH` | `/api/checklists/templates/{slug}` | A | Edit authored template |
|
||||
| `DELETE` | `/api/checklists/templates/{slug}` | A | Delete authored template |
|
||||
| `PATCH` | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
|
||||
| `GET` | `/api/checklists/templates/{slug}/shares` | B | List grants |
|
||||
| `POST` | `/api/checklists/templates/{slug}/shares` | B | Grant share |
|
||||
| `DELETE` | `/api/checklists/shares/{id}` | B | Revoke share |
|
||||
| `POST` | `/api/admin/checklists/{slug}/promote` | B | Admin promote to global |
|
||||
| `POST` | `/api/admin/checklists/{slug}/demote` | B | Admin demote |
|
||||
| `GET` | `/api/checklists/gallery` | C | Browse all firm + global templates |
|
||||
|
||||
## 6. Instance snapshot lifecycle
|
||||
|
||||
**On Create (`ChecklistInstanceService.Create`):**
|
||||
1. Resolve slug via `catalog.Find(userID, slug)` — enforces visibility.
|
||||
2. `snapshot = catalog.SnapshotBody(userID, slug)` — captures the
|
||||
template body (groups + items) at this moment, as JSONB.
|
||||
3. Insert into `checklist_instances` with
|
||||
`template_snapshot = snapshot`, `template_slug = slug`,
|
||||
`state = '{}'::jsonb`.
|
||||
|
||||
**On Read (`ChecklistInstanceService.GetByID`):**
|
||||
- Return the instance with `template_snapshot` if non-null.
|
||||
- If NULL (legacy row created before mig 112), fall back to
|
||||
`catalog.Find(slug)`. Logged at INFO; not a fatal path.
|
||||
|
||||
**On Template Edit (Slice A):**
|
||||
- Owner edits template via PATCH → DB row mutated → `checklists.updated_at`
|
||||
bumped → no propagation. Existing instances continue rendering their
|
||||
snapshot. New instances pick up the edit.
|
||||
- Audit row `event_type='checklist.edited'`,
|
||||
`metadata={ checklist_id, slug, changes:[...] }`.
|
||||
|
||||
**On Template Delete:**
|
||||
- DB row deleted. Instances that snapshotted survive (snapshot is
|
||||
local). Instances that DIDN'T snapshot (NULL) gracefully degrade —
|
||||
service detects "template not found in catalog" and returns the
|
||||
instance with a sentinel "template withdrawn" body (renders a small
|
||||
banner client-side; checkboxes still work because `state` is the
|
||||
source of truth, not the template).
|
||||
|
||||
**On Visibility Narrow (firm → shared → private):**
|
||||
- Existing instances unaffected (snapshot is local; visibility check is
|
||||
on the template, not instance).
|
||||
- New instance attempts fail with `ErrNotVisible` (the user can no
|
||||
longer see the template to instantiate it).
|
||||
|
||||
## 7. Frontend (concise sketch — coder owns the detail)
|
||||
|
||||
### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"
|
||||
|
||||
Add a third tab between "Vorlagen" and "Vorhandene Instanzen":
|
||||
|
||||
```
|
||||
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
|
||||
```
|
||||
|
||||
- **Vorlagen** (existing): static catalog + global-promoted DB
|
||||
templates, grouped by Regime, filter pills (UPC/DE/EPA).
|
||||
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
|
||||
Vorlage" CTA. Each card shows title, description, visibility chip,
|
||||
Aktions-Buttons (Bearbeiten / Teilen / Löschen).
|
||||
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
|
||||
optionally render an "📌 Snapshot" badge when `template_snapshot` is
|
||||
non-null (Slice A backfill marker).
|
||||
|
||||
Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
|
||||
templates not yet promoted — discovery surface).
|
||||
|
||||
### 7.2 `/checklists/new` (NEW — Slice A)
|
||||
|
||||
Authoring wizard. Three steps:
|
||||
1. Metadata — title, description, regime (UPC/DE/EPA/OTHER), court,
|
||||
reference, deadline.
|
||||
2. Sections + items — repeating editor (group title → items[] of
|
||||
{label, note, rule}).
|
||||
3. Visibility — radio: privat / firm-weit. (Sharing flow comes in
|
||||
Slice B.)
|
||||
|
||||
Save → POST `/api/checklists/templates` → redirect to
|
||||
`/checklists/{slug}` detail.
|
||||
|
||||
### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)
|
||||
|
||||
Same wizard, prefilled. Owner-only (404 otherwise).
|
||||
|
||||
### 7.4 `/checklists/{slug}` detail page
|
||||
|
||||
Existing detail page renders the template (static OR authored).
|
||||
Additions:
|
||||
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
|
||||
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
|
||||
entfernen" button (Slice B).
|
||||
- Provenance line under the title: "Erstellt von <author> · <date>"
|
||||
(only for DB templates).
|
||||
|
||||
### 7.5 Share modal (Slice B)
|
||||
|
||||
Triggered by "Teilen" on owner's detail page. Four pickers stacked:
|
||||
- Kollegen (user-picker, multi-select)
|
||||
- Office (chip-select from `offices.All`)
|
||||
- Dezernat (chip-select from `partner_units`)
|
||||
- Projekt (autocomplete from owner-visible projects)
|
||||
|
||||
Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
|
||||
"firm-weit" greys out the picker (firm-weit doesn't need grants).
|
||||
|
||||
Apply → POST grants individually → audit emits one
|
||||
`event_type='checklist.shared'` per grant with
|
||||
`metadata={ recipient_kind, recipient_id, checklist_id }`.
|
||||
|
||||
### 7.6 i18n keys
|
||||
|
||||
~28 new keys (DE+EN) under `checklisten.authoring.*`,
|
||||
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
|
||||
matches existing `checklisten.tab.*` / `checklisten.instances.*`.
|
||||
|
||||
## 8. Audit events
|
||||
|
||||
Org-scope (`paliad.system_audit_log` via a small new helper
|
||||
`SystemAuditLogService.WriteChecklistEvent`):
|
||||
|
||||
| event_type | actor | metadata keys |
|
||||
|----------------------------------|-------------|----------------------------------------------------|
|
||||
| `checklist.authored` | owner | checklist_id, slug, visibility |
|
||||
| `checklist.edited` | owner | checklist_id, slug, changed_fields[] |
|
||||
| `checklist.visibility_changed` | owner | checklist_id, slug, from, to |
|
||||
| `checklist.shared` | owner | checklist_id, slug, recipient_kind, recipient_id |
|
||||
| `checklist.unshared` | owner | checklist_id, slug, recipient_kind, recipient_id |
|
||||
| `checklist.promoted_global` | global_admin| checklist_id, slug, owner_id |
|
||||
| `checklist.demoted` | global_admin| checklist_id, slug, target_visibility |
|
||||
| `checklist.deleted` | owner OR ga | checklist_id, slug, was_visibility |
|
||||
|
||||
Project-scope (`paliad.project_events` — existing helper
|
||||
`insertProjectEventWithMeta`): existing checklist-instance events
|
||||
unchanged. NO new project_events types for templates — templates are
|
||||
not project-scoped.
|
||||
|
||||
`AuditService.ListEntries` already reads from `system_audit_log` via
|
||||
the UNION ALL branch added in t-paliad-214 — no changes needed there;
|
||||
new event_types surface automatically in the audit log UI.
|
||||
|
||||
## 9. Slice plan
|
||||
|
||||
### Slice A — Foundation (~700 LoC)
|
||||
|
||||
**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
|
||||
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
|
||||
no share table yet; visibility limited to private/firm.
|
||||
|
||||
**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
|
||||
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
|
||||
`SystemAuditLogService.WriteChecklistEvent` helper.
|
||||
|
||||
**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
|
||||
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.
|
||||
|
||||
**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
|
||||
`/checklists/{slug}/edit`, owner controls on detail page.
|
||||
|
||||
**Test pass:** unit tests for slug validation, snapshot capture,
|
||||
visibility predicate (without share rows), audit emit, fallback to
|
||||
catalog when snapshot NULL.
|
||||
|
||||
**No share, no admin promote, no gallery.** Ships immediately useful
|
||||
for solo authoring + firm-wide publishing.
|
||||
|
||||
### Slice B — Sharing + Promotion (~600 LoC)
|
||||
|
||||
**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
|
||||
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
|
||||
sub-enum (Slice A schema already includes 'shared' as valid value —
|
||||
just no grants point at it yet).
|
||||
|
||||
**Service:** `ChecklistShareService`, `ChecklistPromotionService`.
|
||||
|
||||
**Endpoints:** shares endpoints + admin promote/demote.
|
||||
|
||||
**Frontend:** Share modal, "Make global" admin button on detail page,
|
||||
share-grant chip list on detail page (owner-only).
|
||||
|
||||
**Audit:** new event_types (shared, unshared, promoted_global, demoted).
|
||||
|
||||
### Slice C — Discoverability + UX polish (~400 LoC)
|
||||
|
||||
**Gallery page** `/checklists/gallery`: browses every template the user
|
||||
can see that's NOT their own, grouped by Regime / Author / Recency.
|
||||
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.
|
||||
|
||||
**Backfill** existing `checklist_instances` with `template_snapshot`
|
||||
via a one-off migration (mig 114) — pure data move, no schema change.
|
||||
After backfill, the catalog-fallback path can be removed (deferred to
|
||||
Slice D / cleanup).
|
||||
|
||||
**Optional**:
|
||||
- "Vorlage kopieren" action — clone an existing template (static OR
|
||||
authored) into the caller's "Meine Vorlagen" for personal adaptation.
|
||||
- Per-template instance counter ("12 Kollegen haben diese Vorlage
|
||||
benutzt") — surfaced from `checklist_instances` group-by.
|
||||
|
||||
## 10. Trade-offs flagged
|
||||
|
||||
1. **Hybrid catalog (static + DB).** Two sources of truth means two
|
||||
slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
|
||||
reserved-list rejection. Refactoring all static templates into DB
|
||||
loses the git review trail; the hybrid is the right cost.
|
||||
2. **Polymorphism deferred.** A future second sharable entity will need
|
||||
to either copy the `checklist_shares` pattern (cheap but duplicative)
|
||||
or refactor to `entity_shares` (one mig). The refactor is small;
|
||||
premature abstraction now would pay complexity for no current
|
||||
benefit.
|
||||
3. **Snapshot semantics may surprise.** A user who edits their template
|
||||
expecting downstream instances to update will be confused.
|
||||
Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
|
||||
neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
|
||||
detail page that re-snapshots from the current template (preserves
|
||||
the user's checkbox state to the extent items still match).
|
||||
4. **Office membership is set-membership, not hierarchy.** Sharing to
|
||||
"munich" reaches every user with `office='munich'` OR
|
||||
`'munich' = ANY(additional_offices)`. There's no concept of "Munich
|
||||
plus its sub-teams" because offices don't nest in paliad. Fine.
|
||||
5. **Partner-unit membership join is N+1 on the predicate.** Each
|
||||
visibility check touches `partner_unit_members` if any partner-unit
|
||||
share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
|
||||
already exist (per mig 027 lineage); the join is single-row.
|
||||
6. **Share-to-project recipient resolution uses
|
||||
`can_see_project(s.recipient_project_id)`.** That predicate reads
|
||||
`auth.uid()` from the session, so it works correctly inside our
|
||||
SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
|
||||
in `paliad.can_see_project` source — same pattern that
|
||||
`effective_project_admin` uses in mig 111.
|
||||
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
|
||||
Means a global_admin can edit content of any user's template, not
|
||||
just visibility. This is intentional for catalog hygiene
|
||||
(correcting typos, removing inflammatory content) but should be used
|
||||
sparingly and audited. The audit log captures every
|
||||
global_admin-attributed edit via `checklist.edited` with actor_id.
|
||||
8. **Instance snapshot fallback path lives indefinitely.** Existing
|
||||
pre-mig-112 instances stay NULL until Slice C backfills. The
|
||||
fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
|
||||
no hot-path concern — but it's "dead code" once the backfill runs.
|
||||
Acceptable until Slice C.
|
||||
9. **Cascade on owner deletion.** If an authored template's owner is
|
||||
removed (`paliad.users.id` cascades), the template is wiped along
|
||||
with all its shares. Existing instances survive via snapshot. The
|
||||
alternative (transfer ownership to global_admin on user-delete) is
|
||||
more polite but introduces governance questions ("which admin?")
|
||||
that aren't worth Slice A complexity. Flag for Slice C if it bites.
|
||||
10. **Slug uniqueness across origins enforced application-side.**
|
||||
The static catalog is in-memory at boot. If a deploy adds a static
|
||||
slug that collides with an existing DB slug, the deploy boots
|
||||
cleanly but the DB row becomes unreachable via the catalog read
|
||||
layer (static wins on slug lookup). Mitigation: a boot-time
|
||||
integrity check in `cmd/server/main.go` logs WARN if collision
|
||||
detected. Owner can rename their template manually via the edit UI.
|
||||
|
||||
## 11. m's decisions ledger (all defaulted to (R) per task brief)
|
||||
|
||||
Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
|
||||
material." I have not escalated; all picks below default to (R).
|
||||
|
||||
| # | Question | (R) pick |
|
||||
|---|---------------------------------------------------------|-------------------------------------------|
|
||||
| 1 | Storage model for authored templates | Hybrid: keep static catalog + new `paliad.checklists` DB table |
|
||||
| 2 | Instance lifecycle on template edit | **Snapshot** at instance create (NOT propagate) |
|
||||
| 3 | Visibility enum values | `private`, `shared`, `firm`, `global` |
|
||||
| 4 | Share recipients | user, office, partner_unit, project (4 axes) |
|
||||
| 5 | Share-to-project resolution | Reuse `can_see_project` (visibility, not just team rows) |
|
||||
| 6 | Promotion authority | `global_admin` only (no per-project admin promote in v1) |
|
||||
| 7 | Demotion target | `global → firm` (preserves visibility for in-flight instances) |
|
||||
| 8 | Slug strategy | `u-` prefix on authored, application-side collision check vs static |
|
||||
| 9 | Polymorphic share table (`entity_shares`) vs scoped | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
|
||||
| 10| Authoring i18n | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
|
||||
| 11| Audit sink for template lifecycle | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
|
||||
| 12| Slice ordering | A (foundation) → B (share + promote) → C (gallery + backfill) |
|
||||
|
||||
Material escalation list: empty. If m disagrees with any of the above,
|
||||
amend §11 in the next inventor shift; the schema is designed to be
|
||||
forward-compatible with most reversals (e.g. flipping snapshot →
|
||||
propagate is a service-layer change, not a schema change).
|
||||
|
||||
## 12. Acceptance criteria — Slice A
|
||||
|
||||
1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
|
||||
on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
|
||||
`paliad` schema).
|
||||
2. **`/api/checklists` returns merged catalog** — static templates
|
||||
plus DB templates the caller can see (visibility ∈ {firm, global}
|
||||
OR owner = caller).
|
||||
3. **POST `/api/checklists/templates`** creates a row, returns the
|
||||
created template with auto-generated `u-…` slug, emits
|
||||
`checklist.authored` audit row.
|
||||
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
|
||||
fields, rejects 403 from non-owner non-admin, emits
|
||||
`checklist.edited`.
|
||||
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
|
||||
private↔firm; rejects `shared` and `global` in Slice A (those land
|
||||
in Slice B); emits `checklist.visibility_changed`.
|
||||
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
|
||||
existing instances survive via snapshot.
|
||||
7. **Instance create snapshots the template body** —
|
||||
`template_snapshot` non-null on every new instance row.
|
||||
8. **Legacy instances (NULL snapshot) still render** via catalog
|
||||
fallback (covered by a regression test).
|
||||
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
|
||||
CTA navigates to `/checklists/new`; wizard saves successfully.
|
||||
10. **`go build ./... && go vet ./... && go test ./internal/...`
|
||||
clean.** `bun run build` clean (i18n key count incremented by ~20).
|
||||
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
|
||||
template; setting visibility to `firm` makes it visible to a second
|
||||
tester account; deleting the template doesn't break existing
|
||||
instances.
|
||||
|
||||
## 13. Recommended implementer
|
||||
|
||||
Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
|
||||
directive 2026-05-06). Substrate is well-trodden:
|
||||
|
||||
- Migration shape mirrors mig 111 (gauss) for the predicate function +
|
||||
policy replacement pattern.
|
||||
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
|
||||
emit + visibility check.
|
||||
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
|
||||
- Frontend tab pattern mirrors the existing
|
||||
`entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.
|
||||
|
||||
Novel pieces:
|
||||
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
|
||||
prototype before committing to the full slice. Pure function; easy
|
||||
to unit-test.
|
||||
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
|
||||
into a STABLE SECURITY DEFINER function; pattern matches mig 111
|
||||
exactly.
|
||||
|
||||
Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
|
||||
or one branch with three commits — coder's call. Each slice ends with
|
||||
acceptance criteria; head merges between slices for fast feedback.
|
||||
|
||||
## 14. Out of scope (explicitly)
|
||||
|
||||
- Importing checklists from external sources (Notion, Trello, .docx).
|
||||
- Approval-policy gating on checklist edits (admin pre-publish review).
|
||||
- Cross-firm template marketplace.
|
||||
- Translation workflow (de↔en) for authored templates — Slice A
|
||||
ships single-language; if firm appetite shows up post-launch, file
|
||||
a follow-up.
|
||||
- Static-catalog editor UI (the static templates remain code-only).
|
||||
- Versioning UI ("show me the version this instance was created from")
|
||||
— snapshot is captured; surfacing it is Slice C polish.
|
||||
|
||||
---
|
||||
|
||||
**Inventor parked per gate protocol.** No auto-shift to coder. Head
|
||||
decides: same worker as `/mai-coder` with this brief, fresh coder, or
|
||||
rescope. Slice ordering A → B → C is independent enough that the head
|
||||
can also greenlight Slice A alone and re-design B/C after Slice A
|
||||
ships.
|
||||
@@ -10,6 +10,7 @@ import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
|
||||
import { renderChecklists } from "./src/checklists";
|
||||
import { renderChecklistsAuthor } from "./src/checklists-author";
|
||||
import { renderChecklistsDetail } from "./src/checklists-detail";
|
||||
import { renderChecklistsInstance } from "./src/checklists-instance";
|
||||
import { renderCourts } from "./src/courts";
|
||||
@@ -20,10 +21,8 @@ import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
|
||||
import { renderAppointmentsNew } from "./src/appointments-new";
|
||||
import { renderAppointmentsDetail } from "./src/appointments-detail";
|
||||
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
|
||||
import { renderSettings } from "./src/settings";
|
||||
import { renderDashboard } from "./src/dashboard";
|
||||
import { renderAgenda } from "./src/agenda";
|
||||
@@ -245,6 +244,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
|
||||
join(import.meta.dir, "src/client/checklists.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-author.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-detail.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-instance.ts"),
|
||||
join(import.meta.dir, "src/client/courts.ts"),
|
||||
@@ -255,10 +255,8 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-new.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-detail.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-calendar.ts"),
|
||||
join(import.meta.dir, "src/client/settings.ts"),
|
||||
join(import.meta.dir, "src/client/dashboard.ts"),
|
||||
join(import.meta.dir, "src/client/agenda.ts"),
|
||||
@@ -370,6 +368,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
|
||||
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
|
||||
await Bun.write(join(DIST, "checklists-author.html"), renderChecklistsAuthor());
|
||||
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
|
||||
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
|
||||
await Bun.write(join(DIST, "courts.html"), renderCourts());
|
||||
@@ -384,10 +383,8 @@ async function build() {
|
||||
await Bun.write(join(DIST, "events.html"), renderEvents());
|
||||
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
|
||||
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
|
||||
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
|
||||
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
|
||||
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
|
||||
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
|
||||
await Bun.write(join(DIST, "settings.html"), renderSettings());
|
||||
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
|
||||
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
|
||||
|
||||
@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-team-actions">
|
||||
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
|
||||
Konto direkt anlegen
|
||||
</button>
|
||||
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
|
||||
Bestehendes Konto onboarden
|
||||
</button>
|
||||
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
|
||||
Creates BOTH the auth.users row (via Supabase Admin API) and
|
||||
the paliad.users row in one click. New user is visible in
|
||||
dropdowns immediately. */}
|
||||
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
|
||||
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">×</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ält eine E-Mail mit einem Link, ü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>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="appointments.kalender.title">Terminkalender — 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ü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">←</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ächster Monat">→</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ä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">×</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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/checklists-author.tsx
Normal file
120
frontend/src/checklists-author.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Authoring wizard for paliad.checklists. Both /checklists/new and
|
||||
// /checklists/templates/{slug}/edit serve this same bundle; the client reads
|
||||
// window.location.pathname to decide create vs edit mode.
|
||||
export function renderChecklistsAuthor(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="checklisten.author.title">Vorlage erstellen — 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. „UPC SoC — interne Checkliste“.</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ö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> — <span data-i18n="checklisten.author.visibility.private.hint">Nur fü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> — <span data-i18n="checklisten.author.visibility.firm.hint">Fü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ü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>
|
||||
);
|
||||
}
|
||||
@@ -39,12 +39,28 @@ export function renderChecklistsDetail(): string {
|
||||
<div>
|
||||
<h1 id="checklist-title"> </h1>
|
||||
<p className="tool-subtitle" id="checklist-subtitle"> </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öschen</button>
|
||||
{/* global_admin controls — revealed by the client
|
||||
when /api/me reports global_role='global_admin'. */}
|
||||
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
|
||||
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
|
||||
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
|
||||
<span data-i18n="checklisten.feedback.btn">Feedback</span>
|
||||
</button>
|
||||
@@ -122,6 +138,65 @@ export function renderChecklistsDetail(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
|
||||
opens it. Four recipient kinds in a single modal: pick the kind,
|
||||
then the matching entity (user / office / partner_unit / project). */}
|
||||
<div className="modal-overlay" id="share-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
|
||||
<button className="modal-close" id="share-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label data-i18n="checklisten.share.kind">Empfä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">— auswählen —</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">— auswählen —</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">— auswählen —</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">— auswählen —</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">
|
||||
|
||||
@@ -58,6 +58,10 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
<p className="tool-subtitle" id="instance-template-title"> </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 < template.version. */}
|
||||
<div id="instance-outdated-slot" />
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
|
||||
@@ -118,6 +122,21 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slice C: template-diff modal — opened from the
|
||||
"Änderungen anzeigen" button on the outdated banner. */}
|
||||
<div className="modal-overlay" id="instance-diff-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.instance.diff.title">Geänderte Punkte</h2>
|
||||
<button className="modal-close" id="instance-diff-close" type="button">×</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ßen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-instance.js"></script>
|
||||
|
||||
@@ -34,6 +34,8 @@ export function renderChecklists(): string {
|
||||
|
||||
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
|
||||
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="mine" href="/checklists?tab=mine" data-i18n="checklisten.tab.mine">Meine Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="gallery" href="/checklists?tab=gallery" data-i18n="checklisten.tab.gallery">Geteilte Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
|
||||
</nav>
|
||||
|
||||
@@ -49,6 +51,36 @@ export function renderChecklists(): string {
|
||||
<div className="checklist-grid" id="checklist-grid" />
|
||||
</section>
|
||||
|
||||
{/* Meine Vorlagen tab — caller's own authored templates */}
|
||||
<section className="entity-tab-panel" id="tab-mine" style="display:none">
|
||||
<div className="tool-actions" style="margin-bottom:1rem">
|
||||
<a href="/checklists/new" className="btn btn-primary" data-i18n="checklisten.mine.new">Neue Vorlage</a>
|
||||
</div>
|
||||
<p className="entity-events-empty" id="checklists-mine-loading" data-i18n="checklisten.mine.loading">Lädt…</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ädt…</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ädt…</p>
|
||||
|
||||
@@ -468,11 +468,125 @@ function initInviteButton() {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
|
||||
// the auth.users row (via Supabase Admin API) and the paliad.users row in
|
||||
// one POST. New user appears in dropdowns immediately. Welcome email with
|
||||
// magic-link is sent by default; admin can opt out via the checkbox.
|
||||
function openAddFullModal() {
|
||||
const modal = document.getElementById("admin-add-full-modal")!;
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
|
||||
fb.style.display = "none";
|
||||
emailField.value = "";
|
||||
nameField.value = "";
|
||||
jobTitleField.value = "";
|
||||
profSel.value = "associate";
|
||||
langSel.value = "de";
|
||||
sendWelcome.checked = true;
|
||||
officeSel.innerHTML = officeOptions("munich");
|
||||
|
||||
modal.style.display = "flex";
|
||||
emailField.focus();
|
||||
}
|
||||
|
||||
function closeAddFullModal() {
|
||||
document.getElementById("admin-add-full-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
function initAddFullModal() {
|
||||
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
|
||||
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeAddFullModal();
|
||||
});
|
||||
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
// Pre-fill the display name from the email local-part the first time the
|
||||
// admin tabs out of the email field — mirrors the existing onboard flow.
|
||||
emailField.addEventListener("blur", () => {
|
||||
if (nameField.value || !emailField.value) return;
|
||||
const local = emailField.value.split("@")[0] ?? "";
|
||||
nameField.value = local
|
||||
.split(/[._-]/)
|
||||
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
||||
.join(" ")
|
||||
.trim();
|
||||
});
|
||||
|
||||
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
fb.style.display = "none";
|
||||
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
email: emailField.value.trim().toLowerCase(),
|
||||
display_name: nameField.value.trim(),
|
||||
office: officeSel.value,
|
||||
job_title: jobTitleField.value.trim() || "Associate",
|
||||
profession: profSel.value,
|
||||
lang: langSel.value,
|
||||
send_welcome_mail: sendWelcome.checked,
|
||||
};
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/admin/users/full", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
// Map two friendly cases inline; everything else surfaces the
|
||||
// server message so the admin can act on it.
|
||||
if (resp.status === 503) {
|
||||
fb.textContent = t("admin.team.add_full.error.unavailable")
|
||||
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
|
||||
} else if (resp.status === 409) {
|
||||
fb.textContent = body.error
|
||||
|| (t("admin.team.add_full.error.email_exists")
|
||||
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
|
||||
} else {
|
||||
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
|
||||
}
|
||||
fb.className = "form-msg form-msg-error";
|
||||
fb.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const created = (await resp.json()) as User;
|
||||
users = users.concat(created);
|
||||
closeAddFullModal();
|
||||
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
|
||||
render();
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initDirectAddModal();
|
||||
initAddFullModal();
|
||||
initInviteButton();
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
title: string;
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
appointment_type?: string;
|
||||
project_reference?: string;
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
let allAppointments: Appointment[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const m = String(month + 1).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function loadAppointments() {
|
||||
// Pull a wide window (current month plus a little buffer either side).
|
||||
// We could narrow this, but the user typically navigates ±1-2 months
|
||||
// and the dataset is small.
|
||||
try {
|
||||
const resp = await fetch("/api/appointments");
|
||||
if (resp.ok) allAppointments = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function appointmentsForDate(iso: string): Appointment[] {
|
||||
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
|
||||
}
|
||||
|
||||
function typeClass(t?: string): string {
|
||||
return t ? `termin-type-${t}` : "termin-type-default";
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const items = appointmentsForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
const grid = document.getElementById("appointment-cal-grid")!;
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(viewYear, viewMonth, 1);
|
||||
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
||||
const hasInMonth = allAppointments.some((tt) => {
|
||||
const iso = tt.start_at.slice(0, 10);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
const empty = document.getElementById("appointment-cal-empty")!;
|
||||
empty.style.display = hasInMonth ? "none" : "";
|
||||
}
|
||||
|
||||
function openPopup(iso: string) {
|
||||
const items = appointmentsForDate(iso);
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const dateEl = document.getElementById("cal-popup-date")!;
|
||||
const list = document.getElementById("cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((tt) => {
|
||||
const akteRef = tt.project_id
|
||||
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
|
||||
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
|
||||
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
|
||||
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
|
||||
${akteRef}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function initPopup() {
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const close = document.getElementById("cal-popup-close")!;
|
||||
close.addEventListener("click", () => (popup.style.display = "none"));
|
||||
popup.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-next")!.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-today")!.addEventListener("click", () => {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadAppointments();
|
||||
render();
|
||||
});
|
||||
135
frontend/src/client/calendar/mount-calendar.test.ts
Normal file
135
frontend/src/client/calendar/mount-calendar.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
bucketByDate,
|
||||
filterByDay,
|
||||
isToday,
|
||||
isoDate,
|
||||
shift,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
type CalendarItem,
|
||||
} from "./mount-calendar";
|
||||
|
||||
// Regression tests for t-paliad-224: the calendar bucket / week / shift
|
||||
// helpers underpin both /events Kalender and the Custom Views shape=
|
||||
// calendar. DOM-rendering is covered by manual smoke (frontend tests in
|
||||
// this repo run in plain Node, no jsdom — see verfahrensablauf-core.test
|
||||
// ts comment), so the pure date-math goes here.
|
||||
|
||||
const item = (overrides: Partial<CalendarItem> = {}): CalendarItem => ({
|
||||
kind: "deadline",
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
title: "Klageerwiderung",
|
||||
event_date: "2026-05-08T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isoDate / startOfDay / startOfWeek", () => {
|
||||
test("isoDate pads month + day", () => {
|
||||
expect(isoDate(new Date(2026, 0, 3))).toBe("2026-01-03");
|
||||
expect(isoDate(new Date(2026, 11, 31))).toBe("2026-12-31");
|
||||
});
|
||||
|
||||
test("startOfDay strips time", () => {
|
||||
const d = new Date(2026, 4, 8, 13, 47, 22);
|
||||
const out = startOfDay(d);
|
||||
expect(out.getHours()).toBe(0);
|
||||
expect(out.getMinutes()).toBe(0);
|
||||
expect(out.getSeconds()).toBe(0);
|
||||
expect(isoDate(out)).toBe("2026-05-08");
|
||||
});
|
||||
|
||||
test("startOfWeek snaps to Monday (Mon=0)", () => {
|
||||
// 2026-05-08 was a Friday.
|
||||
const fri = new Date(2026, 4, 8);
|
||||
expect(isoDate(startOfWeek(fri))).toBe("2026-05-04");
|
||||
// Sunday wraps backward to the same Monday, not forward to the next.
|
||||
const sun = new Date(2026, 4, 10);
|
||||
expect(isoDate(startOfWeek(sun))).toBe("2026-05-04");
|
||||
// Monday is its own startOfWeek.
|
||||
const mon = new Date(2026, 4, 4);
|
||||
expect(isoDate(startOfWeek(mon))).toBe("2026-05-04");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift", () => {
|
||||
test("month shift lands on day=1 of the target month", () => {
|
||||
const out = shift(new Date(2026, 4, 15), "month", 1);
|
||||
expect(out.getFullYear()).toBe(2026);
|
||||
expect(out.getMonth()).toBe(5);
|
||||
expect(out.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("month shift wraps year boundary", () => {
|
||||
const out = shift(new Date(2026, 11, 15), "month", 1);
|
||||
expect(out.getFullYear()).toBe(2027);
|
||||
expect(out.getMonth()).toBe(0);
|
||||
expect(out.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("week shift moves seven days", () => {
|
||||
const out = shift(new Date(2026, 4, 8), "week", 1);
|
||||
expect(isoDate(out)).toBe("2026-05-15");
|
||||
});
|
||||
|
||||
test("day shift moves one day", () => {
|
||||
const out = shift(new Date(2026, 4, 8), "day", -1);
|
||||
expect(isoDate(out)).toBe("2026-05-07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketByDate", () => {
|
||||
test("groups items by ISO date and skips items outside the filter", () => {
|
||||
const rows = [
|
||||
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "b", event_date: "2026-05-08T15:30:00Z" }),
|
||||
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
|
||||
// outside the May 2026 filter:
|
||||
item({ id: "x", event_date: "2026-06-01T00:00:00Z" }),
|
||||
// malformed:
|
||||
item({ id: "bad", event_date: "not-a-date" }),
|
||||
];
|
||||
const out = bucketByDate(rows, (d) => d.getMonth() === 4 && d.getFullYear() === 2026);
|
||||
expect(out.size).toBe(2);
|
||||
expect(out.get("2026-05-08")?.map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(out.get("2026-05-09")?.map((r) => r.id)).toEqual(["c"]);
|
||||
expect(out.has("2026-06-01")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByDay", () => {
|
||||
test("returns only items whose calendar day equals the target", () => {
|
||||
const rows = [
|
||||
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "b", event_date: "2026-05-08T23:59:00Z" }),
|
||||
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
|
||||
];
|
||||
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(filterByDay(rows, new Date(2026, 4, 9)).map((r) => r.id)).toEqual(["c"]);
|
||||
expect(filterByDay(rows, new Date(2026, 4, 10))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores malformed dates", () => {
|
||||
const rows = [
|
||||
item({ id: "ok", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "bad", event_date: "not-a-date" }),
|
||||
];
|
||||
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["ok"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToday", () => {
|
||||
test("matches today's calendar day", () => {
|
||||
expect(isToday(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects yesterday + tomorrow", () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
expect(isToday(yesterday)).toBe(false);
|
||||
expect(isToday(tomorrow)).toBe(false);
|
||||
});
|
||||
});
|
||||
579
frontend/src/client/calendar/mount-calendar.ts
Normal file
579
frontend/src/client/calendar/mount-calendar.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
|
||||
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
|
||||
// Lifted from the original shape-calendar.ts so both Custom Views
|
||||
// (shape=calendar) and /events Kalender tab render through the same DOM.
|
||||
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
|
||||
//
|
||||
// Surfaces wire in via mountCalendar(host, items, opts). The returned
|
||||
// handle exposes update(items) for re-render after a filter change and
|
||||
// destroy() for teardown when the host swaps to a different view.
|
||||
|
||||
export type CalendarKind =
|
||||
| "deadline" | "appointment" | "project_event" | "approval_request";
|
||||
|
||||
export interface CalendarItem {
|
||||
kind: CalendarKind;
|
||||
id: string;
|
||||
title: string;
|
||||
/** ISO-8601 timestamp or date string. First 10 chars are read as the
|
||||
* calendar bucket (yyyy-mm-dd). */
|
||||
event_date: string;
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
}
|
||||
|
||||
export type CalendarView = "month" | "week" | "day";
|
||||
|
||||
export interface CalendarOpts {
|
||||
/** Initial view if URL has no override (or urlState is disabled). */
|
||||
defaultView?: CalendarView;
|
||||
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
|
||||
* Surfaces that own their own URL contract pass urlState=false. */
|
||||
urlState?: boolean;
|
||||
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
|
||||
* meaningful when urlState=true. Leave empty for the default
|
||||
* ?cal_view / ?cal_date contract. */
|
||||
urlPrefix?: string;
|
||||
/** Override how a row's href is built. Default routes by kind. */
|
||||
hrefFor?: (item: CalendarItem) => string;
|
||||
}
|
||||
|
||||
export interface CalendarHandle {
|
||||
/** Replace the item set and re-paint at the current view+anchor. */
|
||||
update(items: CalendarItem[]): void;
|
||||
/** Clear host + drop the keep-alive state. After destroy(), the handle
|
||||
* is dead; create a fresh one with mountCalendar(). */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const MAX_PILLS_PER_MONTH_CELL = 3;
|
||||
|
||||
export function mountCalendar(
|
||||
host: HTMLElement,
|
||||
initialItems: CalendarItem[],
|
||||
opts: CalendarOpts = {},
|
||||
): CalendarHandle {
|
||||
let items = initialItems;
|
||||
let view: CalendarView;
|
||||
let anchor: Date;
|
||||
let destroyed = false;
|
||||
|
||||
const urlEnabled = opts.urlState ?? false;
|
||||
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
|
||||
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
|
||||
|
||||
view = urlEnabled
|
||||
? readView(viewParam, opts.defaultView ?? "month")
|
||||
: (opts.defaultView ?? "month");
|
||||
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
|
||||
|
||||
paint();
|
||||
|
||||
return {
|
||||
update(nextItems) {
|
||||
if (destroyed) return;
|
||||
items = nextItems;
|
||||
paint();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
host.innerHTML = "";
|
||||
},
|
||||
};
|
||||
|
||||
// --- paint -----------------------------------------------------------
|
||||
|
||||
function paint(): void {
|
||||
if (destroyed) return;
|
||||
host.innerHTML = "";
|
||||
|
||||
// Mobile fallback notice (<600px). Documented in design-calendar-
|
||||
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
|
||||
// notice just nudges users toward a friendlier view.
|
||||
if (typeof window !== "undefined" && window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
wrap.appendChild(renderToolbar());
|
||||
if (view === "month") {
|
||||
wrap.appendChild(renderMonth());
|
||||
} else if (view === "week") {
|
||||
wrap.appendChild(renderWeek());
|
||||
} else {
|
||||
wrap.appendChild(renderDay());
|
||||
}
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
function setView(nextView: CalendarView, nextAnchor: Date): void {
|
||||
view = nextView;
|
||||
anchor = nextAnchor;
|
||||
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
|
||||
paint();
|
||||
}
|
||||
|
||||
// --- Toolbar ---------------------------------------------------------
|
||||
|
||||
function renderToolbar(): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "views-calendar-toolbar";
|
||||
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "views-calendar-view-switcher agenda-chip-row";
|
||||
switcher.setAttribute("role", "tablist");
|
||||
for (const v of ["month", "week", "day"] as CalendarView[]) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
|
||||
chip.dataset.calView = v;
|
||||
chip.setAttribute("role", "tab");
|
||||
chip.setAttribute("aria-selected", v === view ? "true" : "false");
|
||||
chip.textContent = t(`cal.view.${v}` as I18nKey);
|
||||
chip.addEventListener("click", () => {
|
||||
if (v === view) return;
|
||||
setView(v, anchor);
|
||||
});
|
||||
switcher.appendChild(chip);
|
||||
}
|
||||
bar.appendChild(switcher);
|
||||
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "views-calendar-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
|
||||
prev.textContent = "‹";
|
||||
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
|
||||
nav.appendChild(prev);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "views-calendar-nav-label";
|
||||
label.textContent = formatRangeLabel(view, anchor);
|
||||
nav.appendChild(label);
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
|
||||
next.textContent = "›";
|
||||
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
|
||||
nav.appendChild(next);
|
||||
|
||||
// "Heute" button — jump back to today in the current view. Adds a
|
||||
// recognisable affordance for the /events Kalender users who relied
|
||||
// on the old toolbar's "Heute" button.
|
||||
const today = document.createElement("button");
|
||||
today.type = "button";
|
||||
today.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
today.textContent = t("cal.today");
|
||||
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
|
||||
nav.appendChild(today);
|
||||
|
||||
if (view !== "month") {
|
||||
const backToMonth = document.createElement("button");
|
||||
backToMonth.type = "button";
|
||||
backToMonth.className = "btn-link views-calendar-back-to-month";
|
||||
backToMonth.textContent = t("cal.day.back_to_month");
|
||||
backToMonth.addEventListener("click", () => setView("month", anchor));
|
||||
nav.appendChild(backToMonth);
|
||||
}
|
||||
|
||||
bar.appendChild(nav);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// --- Month -----------------------------------------------------------
|
||||
|
||||
function renderMonth(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const weekdayKeys: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const byDate = bucketByDate(items, (d) =>
|
||||
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
|
||||
);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
|
||||
const dateKey = isoDate(dayDate);
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
|
||||
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
|
||||
|
||||
const dayLabel = document.createElement("button");
|
||||
dayLabel.type = "button";
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(dayNum);
|
||||
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
dayLabel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setView("day", dayDate);
|
||||
});
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
|
||||
for (const row of visible) ul.appendChild(renderPill(row));
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
const moreBtn = document.createElement("button");
|
||||
moreBtn.type = "button";
|
||||
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
|
||||
moreBtn.textContent = `+${dayRows.length - visible.length}`;
|
||||
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
moreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setView("day", dayDate);
|
||||
});
|
||||
more.appendChild(moreBtn);
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
// --- Week ------------------------------------------------------------
|
||||
|
||||
function renderWeek(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-week";
|
||||
|
||||
const weekStart = startOfWeek(anchor);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-week-grid";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
grid.appendChild(renderWeekColumn(day));
|
||||
}
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWeekColumn(day: Date): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const col = document.createElement("div");
|
||||
col.className = "views-calendar-week-column";
|
||||
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-calendar-week-head";
|
||||
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
|
||||
const dow = document.createElement("span");
|
||||
dow.className = "views-calendar-week-dow";
|
||||
dow.textContent = t(weekdayKey);
|
||||
const dnum = document.createElement("span");
|
||||
dnum.className = "views-calendar-week-dnum";
|
||||
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
head.appendChild(dow);
|
||||
head.appendChild(dnum);
|
||||
col.appendChild(head);
|
||||
|
||||
const dayRows = filterByDay(items, day);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-week-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
col.appendChild(empty);
|
||||
return col;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-week-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "week"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
col.appendChild(ul);
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- Day -------------------------------------------------------------
|
||||
|
||||
function renderDay(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-day-wrap";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const dayRows = filterByDay(items, anchor);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-day-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-day-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "day"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// --- Row rendering ---------------------------------------------------
|
||||
|
||||
function renderPill(row: CalendarItem): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
a.href = hrefFor(row);
|
||||
a.textContent = row.title;
|
||||
a.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
a.addEventListener("click", (e) => e.stopPropagation());
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
|
||||
a.href = hrefFor(row);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
|
||||
a.appendChild(dot);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "views-calendar-row-body";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-calendar-row-title";
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(tDyn("views.kind." + row.kind));
|
||||
if (row.project_reference) metaParts.push(row.project_reference);
|
||||
else if (row.project_title) metaParts.push(row.project_title);
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "views-calendar-row-meta";
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
a.appendChild(body);
|
||||
return a;
|
||||
}
|
||||
|
||||
function hrefFor(row: CalendarItem): string {
|
||||
if (opts.hrefFor) return opts.hrefFor(row);
|
||||
return defaultHrefFor(row);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pure helpers (shared, not closure-bound) ----------------------------
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
|
||||
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
|
||||
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
|
||||
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
|
||||
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
|
||||
}
|
||||
|
||||
function defaultHrefFor(row: CalendarItem): string {
|
||||
switch (row.kind) {
|
||||
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
|
||||
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
|
||||
case "approval_request": return `/inbox`;
|
||||
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
|
||||
}
|
||||
}
|
||||
|
||||
export function bucketByDate(
|
||||
rows: CalendarItem[], filter: (d: Date) => boolean,
|
||||
): Map<string, CalendarItem[]> {
|
||||
const out = new Map<string, CalendarItem[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (!filter(d)) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = out.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else out.set(key, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
|
||||
const key = isoDate(day);
|
||||
return rows.filter((r) => {
|
||||
const d = new Date(r.event_date);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
return isoDate(d) === key;
|
||||
});
|
||||
}
|
||||
|
||||
export function startOfWeek(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const offset = (out.getDay() + 6) % 7;
|
||||
out.setDate(out.getDate() - offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
export function shift(d: Date, view: CalendarView, dir: number): Date {
|
||||
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||||
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
|
||||
}
|
||||
|
||||
export function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
export function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatRangeLabel(view: CalendarView, anchor: Date): string {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (view === "month") {
|
||||
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(anchor);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return formatWeekHeader(start, end, lang);
|
||||
}
|
||||
return anchor.toLocaleDateString(lang, {
|
||||
weekday: "short", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekHeader(start: Date, end: Date, lang: string): string {
|
||||
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
function firstAnchor(rows: CalendarItem[]): Date {
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return startOfDay(d);
|
||||
}
|
||||
return startOfDay(new Date());
|
||||
}
|
||||
|
||||
function paramName(prefix: string | undefined, base: string): string {
|
||||
if (!prefix) return base;
|
||||
return `${prefix}_${base}`;
|
||||
}
|
||||
|
||||
function readView(viewParam: string, fallback: CalendarView): CalendarView {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(viewParam);
|
||||
if (raw === "month" || raw === "week" || raw === "day") return raw;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
|
||||
if (typeof window === "undefined") return firstAnchor(rows);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(dateParam);
|
||||
if (raw) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
return firstAnchor(rows);
|
||||
}
|
||||
|
||||
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(viewParam, view);
|
||||
url.searchParams.set(dateParam, isoDate(anchor));
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
365
frontend/src/client/checklists-author.ts
Normal file
365
frontend/src/client/checklists-author.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
// Authoring wizard for paliad.checklists. Serves both /checklists/new
|
||||
// (create) and /checklists/templates/{slug}/edit (edit). The HTML bundle is the
|
||||
// same; this client reads location.pathname to decide which mode to
|
||||
// boot into.
|
||||
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Item {
|
||||
labelDE: string;
|
||||
labelEN: string;
|
||||
noteDE?: string;
|
||||
noteEN?: string;
|
||||
rule?: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
interface Checklist {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
regime: string;
|
||||
court: string;
|
||||
reference: string;
|
||||
deadline: string;
|
||||
lang: string;
|
||||
visibility: string;
|
||||
body: { groups: Group[] };
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,37 @@ interface Checklist {
|
||||
referenceDE?: string;
|
||||
referenceEN?: string;
|
||||
groups: ChecklistGroup[];
|
||||
// Slice B fields — present on authored entries via the merged
|
||||
// catalog response. 'static' templates don't carry these.
|
||||
origin?: "static" | "authored";
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
global_role?: string;
|
||||
}
|
||||
|
||||
interface UserSummary {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
checklist_id: string;
|
||||
recipient_kind: "user" | "office" | "partner_unit" | "project";
|
||||
recipient_label: string;
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
@@ -371,13 +402,320 @@ function rerenderAll() {
|
||||
renderInstances();
|
||||
}
|
||||
|
||||
// --- Slice B: owner actions + admin promote + share modal ----------------
|
||||
|
||||
let me: Me | null = null;
|
||||
let isOwner = false;
|
||||
let isAdmin = false;
|
||||
let shareUsers: UserSummary[] = [];
|
||||
let sharePartnerUnits: PartnerUnit[] = [];
|
||||
let shareProjects: AkteSummary[] = [];
|
||||
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
|
||||
|
||||
async function loadMe(): Promise<Me | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function templateOriginInfo() {
|
||||
return template as unknown as {
|
||||
origin?: string;
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function applyOwnerControls() {
|
||||
const info = templateOriginInfo();
|
||||
const isAuthored = info?.origin === "authored";
|
||||
const provenance = document.getElementById("checklist-provenance")!;
|
||||
if (isAuthored && info?.owner_display_name) {
|
||||
provenance.style.display = "";
|
||||
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
|
||||
} else {
|
||||
provenance.style.display = "none";
|
||||
}
|
||||
|
||||
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
|
||||
isAdmin = !!(me && me.global_role === "global_admin");
|
||||
const ownerOnly = (id: string, show: boolean) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) (el as HTMLElement).style.display = show ? "" : "none";
|
||||
};
|
||||
if (template) {
|
||||
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
|
||||
"href",
|
||||
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
|
||||
);
|
||||
}
|
||||
ownerOnly("btn-edit-template", isOwner);
|
||||
ownerOnly("btn-share-template", isOwner);
|
||||
ownerOnly("btn-delete-template", isOwner);
|
||||
|
||||
// Admin promote/demote — only when an authored template is visible to
|
||||
// an admin, and only the appropriate one for the current visibility.
|
||||
if (isAuthored && isAdmin) {
|
||||
const isGlobal = info?.visibility === "global";
|
||||
ownerOnly("btn-promote-template", !isGlobal);
|
||||
ownerOnly("btn-demote-template", isGlobal);
|
||||
} else {
|
||||
ownerOnly("btn-promote-template", false);
|
||||
ownerOnly("btn-demote-template", false);
|
||||
}
|
||||
}
|
||||
|
||||
function initOwnerActions() {
|
||||
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
const isEN = getLang() === "en";
|
||||
const title = isEN ? template.titleEN : template.titleDE;
|
||||
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
|
||||
if (!window.confirm(msg)) return;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.delete.error"));
|
||||
return;
|
||||
}
|
||||
window.location.href = "/checklists?tab=mine";
|
||||
});
|
||||
|
||||
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
|
||||
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.promote.error"));
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
|
||||
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: "firm" }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.promote.error"));
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSharePickerData() {
|
||||
// Fire all three lookups in parallel — the share modal needs all of
|
||||
// them but doesn't depend on their order.
|
||||
try {
|
||||
const [usersResp, unitsResp, projectsResp] = await Promise.all([
|
||||
fetch("/api/users"),
|
||||
fetch("/api/partner-units"),
|
||||
fetch("/api/projects"),
|
||||
]);
|
||||
shareUsers = usersResp.ok ? await usersResp.json() : [];
|
||||
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
|
||||
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
|
||||
} catch {
|
||||
/* leave whatever loaded */
|
||||
}
|
||||
populateSharePickerOptions();
|
||||
}
|
||||
|
||||
function populateSharePickerOptions() {
|
||||
const userSel = document.getElementById("share-user") as HTMLSelectElement;
|
||||
if (userSel) {
|
||||
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
shareUsers
|
||||
.slice()
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||
.forEach((u) => {
|
||||
if (me && u.id === me.id) return; // can't share with self
|
||||
const opt = document.createElement("option");
|
||||
opt.value = u.id;
|
||||
opt.textContent = `${u.display_name} (${u.email})`;
|
||||
userSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
|
||||
if (officeSel) {
|
||||
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
|
||||
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
officeKeys.forEach((k) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = k;
|
||||
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
officeSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
|
||||
if (puSel) {
|
||||
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
sharePartnerUnits
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((u) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = u.id;
|
||||
opt.textContent = u.name;
|
||||
puSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const prSel = document.getElementById("share-project") as HTMLSelectElement;
|
||||
if (prSel) {
|
||||
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
shareProjects
|
||||
.slice()
|
||||
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
|
||||
.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.reference || ""} — ${p.title}`;
|
||||
prSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
|
||||
activeShareKind = kind;
|
||||
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
|
||||
p.classList.toggle("active", p.dataset.kind === kind);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
|
||||
s.style.display = s.dataset.kind === kind ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initShareModal() {
|
||||
const modal = document.getElementById("share-modal")!;
|
||||
const msg = document.getElementById("share-msg")!;
|
||||
const close = () => { modal.style.display = "none"; };
|
||||
|
||||
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
switchShareKind("user");
|
||||
modal.style.display = "flex";
|
||||
await loadSharePickerData();
|
||||
await renderGrants();
|
||||
});
|
||||
|
||||
document.getElementById("share-close")?.addEventListener("click", close);
|
||||
document.getElementById("share-cancel")?.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
|
||||
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
|
||||
if (!btn) return;
|
||||
switchShareKind(btn.dataset.kind as typeof activeShareKind);
|
||||
});
|
||||
|
||||
document.getElementById("share-submit")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
|
||||
switch (activeShareKind) {
|
||||
case "user": {
|
||||
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_user_id"] = v;
|
||||
break;
|
||||
}
|
||||
case "office": {
|
||||
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_office"] = v;
|
||||
break;
|
||||
}
|
||||
case "partner_unit": {
|
||||
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_partner_unit_id"] = v;
|
||||
break;
|
||||
}
|
||||
case "project": {
|
||||
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_project_id"] = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let errMsg = t("checklisten.share.error.generic");
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) errMsg = j.error;
|
||||
} catch { /* keep generic */ }
|
||||
msg.textContent = errMsg;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("checklisten.share.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
await renderGrants();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderGrants() {
|
||||
if (!template) return;
|
||||
const list = document.getElementById("share-grants-list")!;
|
||||
const empty = document.getElementById("share-grants-empty")!;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
|
||||
const rows: Share[] = resp.ok ? await resp.json() : [];
|
||||
if (rows.length === 0) {
|
||||
list.innerHTML = "";
|
||||
list.appendChild(empty);
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = rows.map((s) => {
|
||||
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
|
||||
return `<li class="share-grant-row" data-id="${esc(s.id)}">
|
||||
<span class="share-grant-kind">${kindLabel}</span>
|
||||
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
|
||||
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
|
||||
</li>`;
|
||||
}).join("");
|
||||
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
|
||||
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
window.alert(t("checklisten.share.grants.revoke.error"));
|
||||
return;
|
||||
}
|
||||
await renderGrants();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initNewInstance();
|
||||
initFeedback();
|
||||
initOwnerActions();
|
||||
initShareModal();
|
||||
onLangChange(rerenderAll);
|
||||
void loadTemplate();
|
||||
void (async () => {
|
||||
me = await loadMe();
|
||||
await loadTemplate();
|
||||
applyOwnerControls();
|
||||
})();
|
||||
void loadInstances();
|
||||
void loadAkten();
|
||||
});
|
||||
|
||||
@@ -40,6 +40,16 @@ interface Instance {
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Slice C — snapshot of the template body + its version at create time.
|
||||
template_snapshot?: { groups: ChecklistGroup[] } | null;
|
||||
template_version?: number | null;
|
||||
}
|
||||
|
||||
// Slice C — augmented Checklist with origin + version, returned by
|
||||
// /api/checklists/{slug}.
|
||||
interface ChecklistWithMeta extends Checklist {
|
||||
origin?: "static" | "authored";
|
||||
version?: number;
|
||||
}
|
||||
|
||||
let template: Checklist | null = null;
|
||||
@@ -155,6 +165,119 @@ function renderHeader() {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
|
||||
}
|
||||
document.getElementById("instance-meta")!.innerHTML = parts.join("");
|
||||
renderOutdatedBadge();
|
||||
}
|
||||
|
||||
// Slice C — show an "outdated" badge when the live template has a
|
||||
// version > the instance's snapshot version. Both values must be
|
||||
// non-null for the comparison to be meaningful (pre-Slice-C instances
|
||||
// have NULL template_version; static templates always have version=1
|
||||
// and never bump).
|
||||
function renderOutdatedBadge() {
|
||||
const slot = document.getElementById("instance-outdated-slot");
|
||||
if (!slot || !instance || !template) return;
|
||||
const tplMeta = template as ChecklistWithMeta;
|
||||
const instVersion = instance.template_version;
|
||||
const tplVersion = tplMeta.version;
|
||||
if (
|
||||
instVersion == null ||
|
||||
tplVersion == null ||
|
||||
tplMeta.origin !== "authored" ||
|
||||
tplVersion <= instVersion
|
||||
) {
|
||||
slot.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const badge = esc(t("checklisten.instance.outdated.badge"));
|
||||
const note = esc(
|
||||
t("checklisten.instance.outdated.note")
|
||||
.replace("{from}", String(instVersion))
|
||||
.replace("{to}", String(tplVersion)),
|
||||
);
|
||||
const action = esc(t("checklisten.instance.outdated.diff"));
|
||||
slot.innerHTML = `<div class="instance-outdated-banner">
|
||||
<span class="instance-outdated-badge">${badge}</span>
|
||||
<span class="instance-outdated-note">${note}</span>
|
||||
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
|
||||
</div>`;
|
||||
document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal);
|
||||
}
|
||||
|
||||
// Shallow diff between two checklist bodies. Compares item label/note/
|
||||
// rule pairs grouped by section title. Items with the same group title
|
||||
// + same label are matched; differences in note/rule are flagged
|
||||
// 'changed'. Items present only in snapshot are 'removed'; items only
|
||||
// in current are 'added'.
|
||||
function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]):
|
||||
{ added: string[]; removed: string[]; changed: string[] } {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const changed: string[] = [];
|
||||
const oldGroups = snapshot?.groups ?? [];
|
||||
const oldMap: Record<string, ChecklistItem> = {};
|
||||
for (const g of oldGroups) {
|
||||
for (const it of g.items) {
|
||||
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
|
||||
oldMap[key] = it;
|
||||
}
|
||||
}
|
||||
const newMap: Record<string, ChecklistItem> = {};
|
||||
for (const g of current) {
|
||||
for (const it of g.items) {
|
||||
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
|
||||
newMap[key] = it;
|
||||
if (!(key in oldMap)) {
|
||||
added.push(it.labelDE || it.labelEN);
|
||||
} else {
|
||||
const o = oldMap[key];
|
||||
if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") ||
|
||||
(o.rule || "") !== (it.rule || "")) {
|
||||
changed.push(it.labelDE || it.labelEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key in oldMap) {
|
||||
if (!(key in newMap)) {
|
||||
const labelParts = key.split("::");
|
||||
removed.push(labelParts[1] || key);
|
||||
}
|
||||
}
|
||||
return { added, removed, changed };
|
||||
}
|
||||
|
||||
function openDiffModal() {
|
||||
if (!template || !instance) return;
|
||||
const modal = document.getElementById("instance-diff-modal")!;
|
||||
const body = document.getElementById("instance-diff-body")!;
|
||||
const diff = diffBodies(instance.template_snapshot, template.groups);
|
||||
const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
|
||||
if (empty) {
|
||||
body.innerHTML = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
|
||||
} else {
|
||||
const section = (label: string, klass: string, items: string[]) => {
|
||||
if (items.length === 0) return "";
|
||||
return `<section class="instance-diff-section ${klass}">
|
||||
<h3>${esc(label)}</h3>
|
||||
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
|
||||
</section>`;
|
||||
};
|
||||
body.innerHTML = [
|
||||
section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added),
|
||||
section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed),
|
||||
section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed),
|
||||
].join("");
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function initDiffModal() {
|
||||
const modal = document.getElementById("instance-diff-modal");
|
||||
if (!modal) return;
|
||||
const close = () => { modal.style.display = "none"; };
|
||||
document.getElementById("instance-diff-close")?.addEventListener("click", close);
|
||||
document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
@@ -389,6 +512,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initPrint();
|
||||
initRename();
|
||||
initFeedback();
|
||||
initDiffModal();
|
||||
onLangChange(renderAll);
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,26 @@ interface ChecklistSummary {
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
itemCount: number;
|
||||
origin?: "static" | "authored";
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
}
|
||||
|
||||
interface MyChecklist {
|
||||
id: string;
|
||||
slug: string;
|
||||
owner_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
regime: string;
|
||||
court: string;
|
||||
reference: string;
|
||||
deadline: string;
|
||||
lang: string;
|
||||
visibility: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
@@ -26,15 +46,20 @@ interface ChecklistInstance {
|
||||
project_title?: string | null;
|
||||
}
|
||||
|
||||
type TabId = "templates" | "instances";
|
||||
type TabId = "templates" | "mine" | "gallery" | "instances";
|
||||
|
||||
const VALID_TABS: TabId[] = ["templates", "instances"];
|
||||
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
|
||||
|
||||
let allChecklists: ChecklistSummary[] = [];
|
||||
let activeRegime = "all";
|
||||
let galleryRegime = "all";
|
||||
let allInstances: ChecklistInstance[] = [];
|
||||
let templatesBySlug: Record<string, ChecklistSummary> = {};
|
||||
let instancesLoaded = false;
|
||||
let myTemplates: MyChecklist[] = [];
|
||||
let myTemplatesLoaded = false;
|
||||
let galleryLoaded = false;
|
||||
let me: { id: string; email: string } | null = null;
|
||||
let activeTab: TabId = "templates";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -208,7 +233,10 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
el.style.display = el.id === `tab-${tab}` ? "" : "none";
|
||||
});
|
||||
if (opts.pushHistory ?? true) {
|
||||
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
|
||||
let newURL = "/checklists";
|
||||
if (tab === "instances") newURL = "/checklists?tab=instances";
|
||||
if (tab === "mine") newURL = "/checklists?tab=mine";
|
||||
if (tab === "gallery") newURL = "/checklists?tab=gallery";
|
||||
if (window.location.pathname + window.location.search !== newURL) {
|
||||
window.history.replaceState({}, "", newURL);
|
||||
}
|
||||
@@ -216,6 +244,155 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
if (tab === "instances") {
|
||||
void loadInstances();
|
||||
}
|
||||
if (tab === "mine") {
|
||||
void loadMyTemplates();
|
||||
}
|
||||
if (tab === "gallery") {
|
||||
void loadGallery();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGallery(force = false) {
|
||||
if (galleryLoaded && !force) return;
|
||||
galleryLoaded = true;
|
||||
// /api/checklists already returns the merged catalog; the gallery
|
||||
// filter just narrows to non-static + non-owned + non-private.
|
||||
if (allChecklists.length === 0) {
|
||||
await loadTemplates();
|
||||
}
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
function renderGallery() {
|
||||
const loading = document.getElementById("checklists-gallery-loading")!;
|
||||
const empty = document.getElementById("checklists-gallery-empty")!;
|
||||
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
const visible = allChecklists.filter((c) => {
|
||||
if (c.origin !== "authored") return false;
|
||||
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
|
||||
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
empty.style.display = "";
|
||||
grid.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
grid.style.display = "";
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
grid.innerHTML = visible.map((c) => {
|
||||
const title = isEN ? c.titleEN : c.titleDE;
|
||||
const desc = isEN ? c.descriptionEN : c.descriptionDE;
|
||||
const court = isEN ? c.courtEN : c.courtDE;
|
||||
const itemsLabel = isEN ? "items" : "Punkte";
|
||||
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
|
||||
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
|
||||
const authorLine = c.owner_display_name
|
||||
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
|
||||
: "";
|
||||
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
|
||||
<div class="checklist-card-top">
|
||||
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
|
||||
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
|
||||
</div>
|
||||
<h2 class="checklist-card-title">${esc(title)}</h2>
|
||||
<p class="checklist-card-desc">${esc(desc)}</p>
|
||||
<p class="checklist-card-court">${esc(court)}</p>
|
||||
${authorLine}
|
||||
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
|
||||
</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function initGalleryFilters() {
|
||||
const container = document.getElementById("checklist-gallery-filters");
|
||||
if (!container) return;
|
||||
container.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
|
||||
if (!btn) return;
|
||||
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
galleryRegime = btn.dataset.regime ?? "all";
|
||||
renderGallery();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch { /* leave me=null */ }
|
||||
}
|
||||
|
||||
async function loadMyTemplates(force = false) {
|
||||
if (myTemplatesLoaded && !force) return;
|
||||
myTemplatesLoaded = true;
|
||||
const resp = await fetch("/api/checklists/templates/mine");
|
||||
if (!resp.ok) {
|
||||
myTemplates = [];
|
||||
} else {
|
||||
myTemplates = (await resp.json()) ?? [];
|
||||
}
|
||||
renderMyTemplates();
|
||||
}
|
||||
|
||||
function renderMyTemplates() {
|
||||
const loading = document.getElementById("checklists-mine-loading")!;
|
||||
const empty = document.getElementById("checklists-mine-empty")!;
|
||||
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (myTemplates.length === 0) {
|
||||
empty.style.display = "";
|
||||
grid.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
grid.style.display = "";
|
||||
|
||||
grid.innerHTML = myTemplates.map((tpl) => {
|
||||
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
|
||||
const visLabel = esc(t(visKey as never) || tpl.visibility);
|
||||
const titleSafe = esc(tpl.title);
|
||||
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
|
||||
<div class="checklist-card-top">
|
||||
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
|
||||
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
|
||||
</div>
|
||||
<h2 class="checklist-card-title">
|
||||
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
|
||||
</h2>
|
||||
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
|
||||
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
|
||||
<div class="checklist-card-actions">
|
||||
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
|
||||
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">Löschen</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
|
||||
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const slug = btn.dataset.slug!;
|
||||
const title = btn.dataset.title || slug;
|
||||
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
|
||||
if (!window.confirm(msg)) return;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.mine.delete.error"));
|
||||
return;
|
||||
}
|
||||
await loadMyTemplates(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
@@ -234,11 +411,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
initGalleryFilters();
|
||||
initTabs();
|
||||
onLangChange(() => {
|
||||
renderTemplates();
|
||||
if (instancesLoaded) renderInstances();
|
||||
if (myTemplatesLoaded) renderMyTemplates();
|
||||
if (galleryLoaded) renderGallery();
|
||||
});
|
||||
void loadMe();
|
||||
void loadTemplates();
|
||||
showTab(parseTab(), { pushHistory: false });
|
||||
});
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
project_reference: string;
|
||||
project_title: string;
|
||||
}
|
||||
|
||||
let allDeadlines: Deadline[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0; // 0-11
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const d = new Date(due.slice(0, 10) + "T00:00:00");
|
||||
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
|
||||
if (diffDays < 0) return "frist-urgency-overdue";
|
||||
if (diffDays <= 7) return "frist-urgency-soon";
|
||||
return "frist-urgency-later";
|
||||
}
|
||||
|
||||
async function loadDeadlines() {
|
||||
try {
|
||||
const resp = await fetch("/api/deadlines?status=all");
|
||||
if (resp.ok) allDeadlines = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function deadlinesForDate(iso: string): Deadline[] {
|
||||
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const m = String(month + 1).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const items = deadlinesForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
const grid = document.getElementById("deadline-cal-grid")!;
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(viewYear, viewMonth, 1);
|
||||
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
||||
const hasInMonth = allDeadlines.some((f) => {
|
||||
const iso = f.due_date.slice(0, 10);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
const empty = document.getElementById("deadline-cal-empty")!;
|
||||
empty.style.display = hasInMonth ? "none" : "";
|
||||
}
|
||||
|
||||
function openPopup(iso: string) {
|
||||
const items = deadlinesForDate(iso);
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const dateEl = document.getElementById("cal-popup-date")!;
|
||||
const list = document.getElementById("cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((f) => {
|
||||
const cls = urgencyClass(f.due_date, f.status);
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="frist-cal-dot ${cls}"></span>
|
||||
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
|
||||
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function initPopup() {
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const close = document.getElementById("cal-popup-close")!;
|
||||
close.addEventListener("click", () => (popup.style.display = "none"));
|
||||
popup.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-next")!.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-today")!.addEventListener("click", () => {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadDeadlines();
|
||||
render();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type FilterHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
@@ -157,8 +158,10 @@ let me: Me | null = null;
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
let eventTypeByID: Map<string, EventType> = new Map();
|
||||
let loadedOK = false;
|
||||
let calYear = 0;
|
||||
let calMonth = 0;
|
||||
// Calendar handle is created lazily when /events first switches into the
|
||||
// Kalender view (t-paliad-224). The handle owns its own month/week/day
|
||||
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
|
||||
let calendar: CalendarHandle | null = null;
|
||||
|
||||
function urlParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.search);
|
||||
@@ -429,12 +432,13 @@ function hideTableAndCalendar() {
|
||||
const calWrap = document.getElementById("events-calendar-wrap");
|
||||
if (tableWrap) tableWrap.style.display = "none";
|
||||
if (calWrap) calWrap.hidden = true;
|
||||
teardownCalendar();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
if (currentView === "calendar") {
|
||||
renderCalendar();
|
||||
renderCalendarView();
|
||||
} else {
|
||||
renderTable();
|
||||
}
|
||||
@@ -557,135 +561,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
|
||||
// plotting an event onto the calendar. Deadlines bucket on due_date;
|
||||
// appointments on start_at's local-date component.
|
||||
function itemDateISO(item: EventListItem): string {
|
||||
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
|
||||
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
|
||||
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
|
||||
// event_date); appointments bucket on start_at (fallback to event_date).
|
||||
function toCalendarItem(item: EventListItem): CalendarItem {
|
||||
let bucketDate: string;
|
||||
if (item.type === "deadline") {
|
||||
const src = item.due_date ?? item.event_date;
|
||||
return src.slice(0, 10);
|
||||
bucketDate = item.due_date ?? item.event_date;
|
||||
} else if (item.start_at) {
|
||||
bucketDate = item.start_at;
|
||||
} else {
|
||||
bucketDate = item.event_date;
|
||||
}
|
||||
if (!item.start_at) return item.event_date.slice(0, 10);
|
||||
const d = new Date(item.start_at);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return {
|
||||
kind: item.type,
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
event_date: bucketDate,
|
||||
project_id: item.project_id,
|
||||
project_title: item.project_title,
|
||||
project_reference: item.project_reference,
|
||||
};
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtMonthYear(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function calDotClass(item: EventListItem): string {
|
||||
// Per-item dot colour. Deadlines reuse the existing urgency palette;
|
||||
// appointments get their own colour so they're visually distinct from
|
||||
// deadlines on a mixed (Beides) calendar.
|
||||
if (item.type === "appointment") return "events-cal-dot-appointment";
|
||||
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const wrap = document.getElementById("events-calendar-wrap")!;
|
||||
const grid = document.getElementById("events-cal-grid")!;
|
||||
const empty = document.getElementById("events-cal-empty") as HTMLElement;
|
||||
const monthLabel = document.getElementById("events-cal-month-label")!;
|
||||
function renderCalendarView() {
|
||||
const host = document.getElementById("events-calendar-wrap");
|
||||
if (!host) return;
|
||||
const tableEmpty = document.getElementById("events-empty")!;
|
||||
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
|
||||
|
||||
// Calendar always renders the visible month from allItems, regardless of
|
||||
// pristine vs filtered state — empty calendar is allowed (the per-month
|
||||
// empty hint communicates "no items in this month" without confusing it
|
||||
// with the table-mode "no items at all" empty state).
|
||||
tableEmpty.style.display = "none";
|
||||
tableEmptyFiltered.style.display = "none";
|
||||
wrap.hidden = false;
|
||||
(host as HTMLElement).hidden = false;
|
||||
|
||||
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
|
||||
|
||||
const firstDay = new Date(calYear, calMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
|
||||
const byDate = new Map<string, EventListItem[]>();
|
||||
for (const item of allItems) {
|
||||
const iso = itemDateISO(item);
|
||||
const list = byDate.get(iso);
|
||||
if (list) list.push(item);
|
||||
else byDate.set(iso, [item]);
|
||||
const items = allItems.map(toCalendarItem);
|
||||
if (calendar) {
|
||||
calendar.update(items);
|
||||
return;
|
||||
}
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(calYear, calMonth, day);
|
||||
const items = byDate.get(iso) ?? [];
|
||||
const isToday = iso === todayISO;
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
|
||||
// urlState=true: the Kalender tab persists its month/week/day + anchor
|
||||
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
|
||||
// calendar state (per t-paliad-224 §11 Q3 head decision).
|
||||
calendar = mountCalendar(host as HTMLElement, items, {
|
||||
urlState: true,
|
||||
defaultView: "month",
|
||||
});
|
||||
|
||||
const monthStart = isoDate(calYear, calMonth, 1);
|
||||
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
|
||||
const hasInMonth = allItems.some((it) => {
|
||||
const iso = itemDateISO(it);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
empty.hidden = hasInMonth;
|
||||
}
|
||||
|
||||
function openCalPopup(iso: string, items: EventListItem[]) {
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
const dateEl = document.getElementById("events-cal-popup-date")!;
|
||||
const list = document.getElementById("events-cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((it) => {
|
||||
const cls = calDotClass(it);
|
||||
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
|
||||
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
|
||||
const projectLabel = it.project_reference ?? "";
|
||||
const projectCell = projectHref
|
||||
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
|
||||
: "";
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="frist-cal-dot ${cls}"></span>
|
||||
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
|
||||
${projectCell}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
function teardownCalendar() {
|
||||
if (!calendar) return;
|
||||
calendar.destroy();
|
||||
calendar = null;
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
@@ -706,12 +632,18 @@ function applyView() {
|
||||
// Cards view = the original layout (5-card summary + table).
|
||||
// List view = no summary cards, table only — gives more vertical space
|
||||
// and matches users' mental model of a flat list.
|
||||
// Calendar view = month grid; cards + table both hidden.
|
||||
// Calendar view = mountCalendar() canon (month/week/day); cards + table
|
||||
// both hidden. The handle is torn down when the user leaves Kalender
|
||||
// so its URL state isn't reapplied to other shapes.
|
||||
summary.style.display = currentView === "cards" ? "" : "none";
|
||||
tableWrap.style.display = currentView === "calendar" ? "none" : "";
|
||||
calWrap.hidden = currentView !== "calendar";
|
||||
|
||||
if (currentView === "calendar" && loadedOK) renderCalendar();
|
||||
if (currentView === "calendar") {
|
||||
if (loadedOK) renderCalendarView();
|
||||
} else {
|
||||
teardownCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
function wireRowHandlers(tbody: HTMLElement) {
|
||||
@@ -1013,12 +945,10 @@ function initFilters() {
|
||||
}
|
||||
|
||||
function initView() {
|
||||
// Calendar always opens on the current month — month navigation is
|
||||
// local to the view (cheap pagination, doesn't refetch).
|
||||
const now = new Date();
|
||||
calYear = now.getFullYear();
|
||||
calMonth = now.getMonth();
|
||||
|
||||
// Kalender state (view + anchor) lives inside mountCalendar; no
|
||||
// events-page-level wiring needed. The view chips below switch
|
||||
// between Karten / Liste / Kalender; applyView() handles the
|
||||
// mount + teardown.
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = btn.dataset.eventView as EventView;
|
||||
@@ -1028,31 +958,6 @@ function initView() {
|
||||
syncURLParams();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
|
||||
calMonth -= 1;
|
||||
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-next")?.addEventListener("click", () => {
|
||||
calMonth += 1;
|
||||
if (calMonth > 11) { calMonth = 0; calYear += 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-today")?.addEventListener("click", () => {
|
||||
const t = new Date();
|
||||
calYear = t.getFullYear();
|
||||
calMonth = t.getMonth();
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
|
||||
popup.style.display = "none";
|
||||
});
|
||||
popup?.addEventListener("click", (e) => {
|
||||
if (e.target === popup) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initSummaryCards() {
|
||||
|
||||
@@ -555,7 +555,101 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"checklisten.heading": "Checklisten",
|
||||
"checklisten.subtitle": "Interaktive Checklisten f\u00fcr typische Verfahrensschritte vor UPC, BPatG und EPA. Abhaken, ausdrucken, kein Punkt vergessen.",
|
||||
"checklisten.tab.templates": "Vorlagen",
|
||||
"checklisten.tab.mine": "Meine Vorlagen",
|
||||
"checklisten.tab.instances": "Vorhandene Instanzen",
|
||||
"checklisten.mine.empty": "Sie haben noch keine eigene Vorlage angelegt.",
|
||||
"checklisten.tab.gallery": "Geteilte Vorlagen",
|
||||
"checklisten.gallery.empty": "Noch keine geteilten Vorlagen sichtbar.",
|
||||
"checklisten.filter.other": "Sonstige",
|
||||
"checklisten.instance.outdated.badge": "Vorlage aktualisiert",
|
||||
"checklisten.instance.outdated.note": "Die zugrundeliegende Vorlage wurde seit dem Anlegen dieser Instanz aktualisiert (v{from} → v{to}).",
|
||||
"checklisten.instance.outdated.diff": "Änderungen anzeigen",
|
||||
"checklisten.instance.diff.title": "Geänderte Punkte",
|
||||
"checklisten.instance.diff.close": "Schließen",
|
||||
"checklisten.instance.diff.added": "Neu",
|
||||
"checklisten.instance.diff.removed": "Entfernt",
|
||||
"checklisten.instance.diff.changed": "Geändert",
|
||||
"checklisten.instance.diff.empty": "Keine inhaltlichen Unterschiede in den Punkten.",
|
||||
"checklisten.instance.diff.error": "Vergleich fehlgeschlagen.",
|
||||
"checklisten.mine.new": "Neue Vorlage",
|
||||
"checklisten.mine.loading": "Lädt…",
|
||||
"checklisten.mine.visibility.private": "Privat",
|
||||
"checklisten.mine.visibility.firm": "Firmenweit",
|
||||
"checklisten.mine.visibility.shared": "Geteilt",
|
||||
"checklisten.mine.visibility.global": "Im Katalog",
|
||||
"checklisten.mine.edit": "Bearbeiten",
|
||||
"checklisten.mine.delete": "Löschen",
|
||||
"checklisten.mine.delete.confirm": "Vorlage „{title}“ wirklich löschen? Bestehende Instanzen bleiben erhalten.",
|
||||
"checklisten.mine.delete.error": "Löschen fehlgeschlagen.",
|
||||
"checklisten.mine.origin.authored": "Eigene Vorlage",
|
||||
"checklisten.author.title": "Vorlage erstellen — Paliad",
|
||||
"checklisten.author.title.edit": "Vorlage bearbeiten — Paliad",
|
||||
"checklisten.author.heading.new": "Neue Checklisten-Vorlage",
|
||||
"checklisten.author.heading.edit": "Vorlage bearbeiten",
|
||||
"checklisten.author.subtitle": "Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten. Sie können sie privat halten oder firmenweit verfügbar machen.",
|
||||
"checklisten.author.field.title": "Titel",
|
||||
"checklisten.author.field.title.hint": "z.B. „UPC SoC — interne Checkliste“.",
|
||||
"checklisten.author.field.description": "Kurzbeschreibung",
|
||||
"checklisten.author.field.regime": "Regime",
|
||||
"checklisten.author.field.court": "Gericht / Behörde",
|
||||
"checklisten.author.field.reference": "Rechtsgrundlage",
|
||||
"checklisten.author.field.deadline": "Deadline (optional)",
|
||||
"checklisten.author.field.lang": "Sprache",
|
||||
"checklisten.author.field.visibility": "Sichtbarkeit",
|
||||
"checklisten.author.visibility.private.hint": "Nur für Sie sichtbar.",
|
||||
"checklisten.author.visibility.firm.hint": "Für alle angemeldeten Kolleginnen und Kollegen sichtbar.",
|
||||
"checklisten.author.groups.heading": "Sektionen und Punkte",
|
||||
"checklisten.author.groups.add": "+ Sektion hinzufügen",
|
||||
"checklisten.author.group.title": "Sektionsname",
|
||||
"checklisten.author.group.remove": "Sektion löschen",
|
||||
"checklisten.author.item.add": "+ Punkt hinzufügen",
|
||||
"checklisten.author.item.label": "Punkt",
|
||||
"checklisten.author.item.note": "Notiz (optional)",
|
||||
"checklisten.author.item.rule": "Vorschrift (optional)",
|
||||
"checklisten.author.item.remove": "Punkt löschen",
|
||||
"checklisten.author.save": "Speichern",
|
||||
"checklisten.author.cancel": "Abbrechen",
|
||||
"checklisten.author.saving": "Speichert…",
|
||||
"checklisten.author.error.title": "Bitte geben Sie einen Titel ein.",
|
||||
"checklisten.author.error.no_groups": "Bitte mindestens eine Sektion mit einem Punkt anlegen.",
|
||||
"checklisten.author.error.generic": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"checklisten.author.error.notfound": "Diese Vorlage existiert nicht oder Sie haben keine Berechtigung sie zu bearbeiten.",
|
||||
"checklisten.detail.edit": "Bearbeiten",
|
||||
"checklisten.detail.delete": "Löschen",
|
||||
"checklisten.detail.share": "Teilen",
|
||||
"checklisten.detail.promote": "Als Firmen-Vorlage hinterlegen",
|
||||
"checklisten.detail.demote": "Aus Katalog entfernen",
|
||||
"checklisten.detail.promote.confirm": "Diese Vorlage in den Firmen-Katalog übernehmen? Alle Kolleg:innen sehen sie dann unter Vorlagen.",
|
||||
"checklisten.detail.demote.confirm": "Vorlage aus dem Firmen-Katalog entfernen? Sie bleibt firmenweit sichtbar.",
|
||||
"checklisten.detail.promote.error": "Übernahme fehlgeschlagen.",
|
||||
"checklisten.detail.delete.confirm": "Vorlage „{title}\" wirklich löschen? Bestehende Instanzen bleiben erhalten.",
|
||||
"checklisten.detail.delete.error": "Löschen fehlgeschlagen.",
|
||||
"checklisten.detail.authored.by": "Erstellt von {author}",
|
||||
"checklisten.detail.visibility": "Sichtbarkeit: {state}",
|
||||
"checklisten.detail.visibility.set.firm": "Für Firma freigeben",
|
||||
"checklisten.detail.visibility.set.private": "Privat schalten",
|
||||
"checklisten.detail.visibility.error": "Sichtbarkeit konnte nicht geändert werden.",
|
||||
"checklisten.share.title": "Vorlage teilen",
|
||||
"checklisten.share.kind": "Empfängertyp",
|
||||
"checklisten.share.kind.user": "Kollege",
|
||||
"checklisten.share.kind.office": "Office",
|
||||
"checklisten.share.kind.partner_unit": "Dezernat",
|
||||
"checklisten.share.kind.project": "Projekt",
|
||||
"checklisten.share.pick": "— auswählen —",
|
||||
"checklisten.share.submit": "Freigeben",
|
||||
"checklisten.share.cancel": "Abbrechen",
|
||||
"checklisten.share.error.pick": "Bitte einen Empfänger auswählen.",
|
||||
"checklisten.share.error.generic": "Freigeben fehlgeschlagen.",
|
||||
"checklisten.share.success": "Freigegeben.",
|
||||
"checklisten.share.grants.heading": "Bestehende Freigaben",
|
||||
"checklisten.share.grants.empty": "Keine Freigaben.",
|
||||
"checklisten.share.grants.revoke": "Entfernen",
|
||||
"checklisten.share.grants.revoke.confirm": "Freigabe entfernen?",
|
||||
"checklisten.share.grants.revoke.error": "Entfernen fehlgeschlagen.",
|
||||
"checklisten.share.grants.recipient.user": "Kollege",
|
||||
"checklisten.share.grants.recipient.office": "Office",
|
||||
"checklisten.share.grants.recipient.partner_unit": "Dezernat",
|
||||
"checklisten.share.grants.recipient.project": "Projekt",
|
||||
"checklisten.instances.all.loading": "L\u00e4dt\u2026",
|
||||
"checklisten.instances.all.empty": "Noch keine Checklisten-Instanzen erfasst. Legen Sie eine \u00fcber den Vorlagen-Tab an.",
|
||||
"checklisten.instances.all.col.template": "Vorlage",
|
||||
@@ -694,7 +788,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.list.heading": "Fristen",
|
||||
"deadlines.list.subtitle": "Persistente Fristen f\u00fcr Ihre Akten. \u00dcberf\u00e4llig, heute, diese Woche, n\u00e4chste Woche \u2014 auf einen Blick.",
|
||||
"deadlines.list.new": "Neue Frist",
|
||||
"deadlines.list.calendar": "Kalenderansicht",
|
||||
"deadlines.summary.overdue": "\u00dcberf\u00e4llig",
|
||||
"deadlines.summary.today": "Heute",
|
||||
"deadlines.summary.thisweek": "Diese Woche",
|
||||
@@ -817,12 +910,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.source.caldav": "CalDAV",
|
||||
"deadlines.source.imported": "Import",
|
||||
|
||||
"deadlines.kalender.title": "Fristenkalender \u2014 Paliad",
|
||||
"deadlines.kalender.heading": "Fristenkalender",
|
||||
"deadlines.kalender.subtitle": "Monats\u00fcbersicht aller Fristen Ihrer Akten.",
|
||||
"deadlines.kalender.list": "Listenansicht",
|
||||
"deadlines.kalender.today": "Heute",
|
||||
"deadlines.kalender.empty": "Keine Fristen im ausgew\u00e4hlten Zeitraum.",
|
||||
"cal.day.mon": "Mo",
|
||||
"cal.day.tue": "Di",
|
||||
"cal.day.wed": "Mi",
|
||||
@@ -1600,7 +1687,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.list.title": "Termine \u2014 Paliad",
|
||||
"appointments.list.heading": "Termine",
|
||||
"appointments.list.subtitle": "Verhandlungen, Besprechungen, Beratungen \u2014 pers\u00f6nlich oder aktenbezogen.",
|
||||
"appointments.list.calendar": "Kalenderansicht",
|
||||
"appointments.list.new": "Neuer Termin",
|
||||
"appointments.summary.today": "Heute",
|
||||
"appointments.summary.thisweek": "Diese Woche",
|
||||
@@ -1656,11 +1742,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.detail.saved": "Gespeichert.",
|
||||
"appointments.detail.delete": "Termin l\u00f6schen",
|
||||
"appointments.detail.delete.confirm": "Diesen Termin wirklich l\u00f6schen?",
|
||||
"appointments.kalender.title": "Terminkalender \u2014 Paliad",
|
||||
"appointments.kalender.heading": "Terminkalender",
|
||||
"appointments.kalender.subtitle": "Monats\u00fcbersicht aller Termine.",
|
||||
"appointments.kalender.list": "Listenansicht",
|
||||
"appointments.kalender.empty": "Keine Termine im ausgew\u00e4hlten Zeitraum.",
|
||||
|
||||
// t-paliad-110 \u2014 unified Events page (rendered on both /deadlines and
|
||||
// /appointments). The user-facing "Fristen" / "Termine" branding stays;
|
||||
@@ -1684,7 +1765,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.view.cards": "Karten",
|
||||
"events.view.list": "Liste",
|
||||
"events.view.calendar": "Kalender",
|
||||
"events.calendar.empty": "Keine Eintr\u00e4ge im ausgew\u00e4hlten Zeitraum.",
|
||||
"caldav.title": "CalDAV-Synchronisation \u2014 Paliad",
|
||||
"caldav.heading": "CalDAV-Synchronisation",
|
||||
"caldav.subtitle": "Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow\u2026). Das Passwort wird verschl\u00fcsselt gespeichert und nie zur\u00fcckgegeben.",
|
||||
@@ -2077,8 +2157,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.heading": "Team-Verwaltung",
|
||||
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
|
||||
"admin.team.search.placeholder": "Nach Name oder E-Mail suchen…",
|
||||
"admin.team.add.full": "Konto direkt anlegen",
|
||||
"admin.team.add.direct": "Bestehendes Konto onboarden",
|
||||
"admin.team.add.invite": "Neue:n Kolleg:in einladen",
|
||||
"admin.team.add_full.title": "Konto direkt anlegen",
|
||||
"admin.team.add_full.body": "Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.",
|
||||
"admin.team.add_full.email": "E-Mail",
|
||||
"admin.team.add_full.name": "Anzeigename",
|
||||
"admin.team.add_full.office": "Standort",
|
||||
"admin.team.add_full.profession": "Profession",
|
||||
"admin.team.add_full.job_title": "Berufsbezeichnung",
|
||||
"admin.team.add_full.lang": "Sprache",
|
||||
"admin.team.add_full.send_welcome": "Willkommens-E-Mail mit Login-Link senden",
|
||||
"admin.team.add_full.cancel": "Abbrechen",
|
||||
"admin.team.add_full.submit": "Anlegen",
|
||||
"admin.team.add_full.feedback.added": "Konto angelegt.",
|
||||
"admin.team.add_full.error.unavailable": "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).",
|
||||
"admin.team.add_full.error.email_exists": "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.",
|
||||
"admin.team.add_full.error.generic": "Konto konnte nicht angelegt werden.",
|
||||
"admin.team.loading": "Lade…",
|
||||
"admin.team.empty": "Keine Treffer.",
|
||||
"admin.team.error.forbidden": "Zugriff nur für Admins.",
|
||||
@@ -3282,7 +3378,101 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"checklisten.heading": "Checklists",
|
||||
"checklisten.subtitle": "Interactive checklists for typical procedural steps before the UPC, German Patent Court, and EPO. Tick off, print, miss nothing.",
|
||||
"checklisten.tab.templates": "Templates",
|
||||
"checklisten.tab.mine": "My templates",
|
||||
"checklisten.tab.instances": "Existing instances",
|
||||
"checklisten.mine.empty": "You haven't authored a template yet.",
|
||||
"checklisten.tab.gallery": "Shared templates",
|
||||
"checklisten.gallery.empty": "No shared templates visible yet.",
|
||||
"checklisten.filter.other": "Other",
|
||||
"checklisten.instance.outdated.badge": "Template updated",
|
||||
"checklisten.instance.outdated.note": "The underlying template has been updated since this instance was created (v{from} → v{to}).",
|
||||
"checklisten.instance.outdated.diff": "Show changes",
|
||||
"checklisten.instance.diff.title": "Changed items",
|
||||
"checklisten.instance.diff.close": "Close",
|
||||
"checklisten.instance.diff.added": "Added",
|
||||
"checklisten.instance.diff.removed": "Removed",
|
||||
"checklisten.instance.diff.changed": "Changed",
|
||||
"checklisten.instance.diff.empty": "No content differences in items.",
|
||||
"checklisten.instance.diff.error": "Diff failed.",
|
||||
"checklisten.mine.new": "New template",
|
||||
"checklisten.mine.loading": "Loading…",
|
||||
"checklisten.mine.visibility.private": "Private",
|
||||
"checklisten.mine.visibility.firm": "Firm-wide",
|
||||
"checklisten.mine.visibility.shared": "Shared",
|
||||
"checklisten.mine.visibility.global": "In catalog",
|
||||
"checklisten.mine.edit": "Edit",
|
||||
"checklisten.mine.delete": "Delete",
|
||||
"checklisten.mine.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
|
||||
"checklisten.mine.delete.error": "Delete failed.",
|
||||
"checklisten.mine.origin.authored": "Your template",
|
||||
"checklisten.author.title": "Author template — Paliad",
|
||||
"checklisten.author.title.edit": "Edit template — Paliad",
|
||||
"checklisten.author.heading.new": "New checklist template",
|
||||
"checklisten.author.heading.edit": "Edit template",
|
||||
"checklisten.author.subtitle": "Author your own checklist with sections and items. Keep it private or open it firm-wide.",
|
||||
"checklisten.author.field.title": "Title",
|
||||
"checklisten.author.field.title.hint": "e.g. \"UPC SoC — internal checklist\".",
|
||||
"checklisten.author.field.description": "Short description",
|
||||
"checklisten.author.field.regime": "Regime",
|
||||
"checklisten.author.field.court": "Court / authority",
|
||||
"checklisten.author.field.reference": "Legal source",
|
||||
"checklisten.author.field.deadline": "Deadline (optional)",
|
||||
"checklisten.author.field.lang": "Language",
|
||||
"checklisten.author.field.visibility": "Visibility",
|
||||
"checklisten.author.visibility.private.hint": "Visible only to you.",
|
||||
"checklisten.author.visibility.firm.hint": "Visible to every authenticated colleague.",
|
||||
"checklisten.author.groups.heading": "Sections and items",
|
||||
"checklisten.author.groups.add": "+ Add section",
|
||||
"checklisten.author.group.title": "Section title",
|
||||
"checklisten.author.group.remove": "Remove section",
|
||||
"checklisten.author.item.add": "+ Add item",
|
||||
"checklisten.author.item.label": "Item",
|
||||
"checklisten.author.item.note": "Note (optional)",
|
||||
"checklisten.author.item.rule": "Rule (optional)",
|
||||
"checklisten.author.item.remove": "Remove item",
|
||||
"checklisten.author.save": "Save",
|
||||
"checklisten.author.cancel": "Cancel",
|
||||
"checklisten.author.saving": "Saving…",
|
||||
"checklisten.author.error.title": "Please enter a title.",
|
||||
"checklisten.author.error.no_groups": "Please add at least one section with one item.",
|
||||
"checklisten.author.error.generic": "Save failed. Please try again.",
|
||||
"checklisten.author.error.notfound": "Template not found or you don't have permission to edit it.",
|
||||
"checklisten.detail.edit": "Edit",
|
||||
"checklisten.detail.delete": "Delete",
|
||||
"checklisten.detail.share": "Share",
|
||||
"checklisten.detail.promote": "Add to firm catalog",
|
||||
"checklisten.detail.demote": "Remove from catalog",
|
||||
"checklisten.detail.promote.confirm": "Add this template to the firm catalog? Every colleague will see it under Templates.",
|
||||
"checklisten.detail.demote.confirm": "Remove this template from the firm catalog? It stays firm-visible.",
|
||||
"checklisten.detail.promote.error": "Promotion failed.",
|
||||
"checklisten.detail.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
|
||||
"checklisten.detail.delete.error": "Delete failed.",
|
||||
"checklisten.detail.authored.by": "Authored by {author}",
|
||||
"checklisten.detail.visibility": "Visibility: {state}",
|
||||
"checklisten.detail.visibility.set.firm": "Share with firm",
|
||||
"checklisten.detail.visibility.set.private": "Make private",
|
||||
"checklisten.detail.visibility.error": "Couldn't change visibility.",
|
||||
"checklisten.share.title": "Share template",
|
||||
"checklisten.share.kind": "Recipient type",
|
||||
"checklisten.share.kind.user": "Colleague",
|
||||
"checklisten.share.kind.office": "Office",
|
||||
"checklisten.share.kind.partner_unit": "Practice unit",
|
||||
"checklisten.share.kind.project": "Project",
|
||||
"checklisten.share.pick": "— pick —",
|
||||
"checklisten.share.submit": "Share",
|
||||
"checklisten.share.cancel": "Cancel",
|
||||
"checklisten.share.error.pick": "Please pick a recipient.",
|
||||
"checklisten.share.error.generic": "Share failed.",
|
||||
"checklisten.share.success": "Shared.",
|
||||
"checklisten.share.grants.heading": "Existing grants",
|
||||
"checklisten.share.grants.empty": "No grants.",
|
||||
"checklisten.share.grants.revoke": "Remove",
|
||||
"checklisten.share.grants.revoke.confirm": "Remove this grant?",
|
||||
"checklisten.share.grants.revoke.error": "Revoke failed.",
|
||||
"checklisten.share.grants.recipient.user": "Colleague",
|
||||
"checklisten.share.grants.recipient.office": "Office",
|
||||
"checklisten.share.grants.recipient.partner_unit": "Practice unit",
|
||||
"checklisten.share.grants.recipient.project": "Project",
|
||||
"checklisten.instances.all.loading": "Loading…",
|
||||
"checklisten.instances.all.empty": "No checklist instances yet. Create one from the Templates tab.",
|
||||
"checklisten.instances.all.col.template": "Template",
|
||||
@@ -3421,7 +3611,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.list.heading": "Deadlines",
|
||||
"deadlines.list.subtitle": "Persistent deadlines for your matters. Overdue, today, this week, next week \u2014 at a glance.",
|
||||
"deadlines.list.new": "New deadline",
|
||||
"deadlines.list.calendar": "Calendar view",
|
||||
"deadlines.summary.overdue": "Overdue",
|
||||
"deadlines.summary.today": "Today",
|
||||
"deadlines.summary.thisweek": "This week",
|
||||
@@ -3544,12 +3733,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.source.caldav": "CalDAV",
|
||||
"deadlines.source.imported": "Import",
|
||||
|
||||
"deadlines.kalender.title": "Deadline calendar \u2014 Paliad",
|
||||
"deadlines.kalender.heading": "Deadline calendar",
|
||||
"deadlines.kalender.subtitle": "Monthly view of all deadlines on your matters.",
|
||||
"deadlines.kalender.list": "List view",
|
||||
"deadlines.kalender.today": "Today",
|
||||
"deadlines.kalender.empty": "No deadlines in the selected period.",
|
||||
"cal.day.mon": "Mon",
|
||||
"cal.day.tue": "Tue",
|
||||
"cal.day.wed": "Wed",
|
||||
@@ -3579,6 +3762,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.day.prev": "Previous day",
|
||||
"cal.day.next": "Next day",
|
||||
"cal.day.back_to_month": "Back to month",
|
||||
"cal.today": "Today",
|
||||
"cal.day.open_day": "Open day view",
|
||||
"cal.day.no_entries": "Nothing scheduled this day.",
|
||||
|
||||
@@ -4313,7 +4497,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.list.title": "Appointments \u2014 Paliad",
|
||||
"appointments.list.heading": "Appointments",
|
||||
"appointments.list.subtitle": "Hearings, meetings, consultations \u2014 personal or matter-linked.",
|
||||
"appointments.list.calendar": "Calendar view",
|
||||
"appointments.list.new": "New appointment",
|
||||
"appointments.summary.today": "Today",
|
||||
"appointments.summary.thisweek": "This week",
|
||||
@@ -4369,11 +4552,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.detail.saved": "Saved.",
|
||||
"appointments.detail.delete": "Delete appointment",
|
||||
"appointments.detail.delete.confirm": "Really delete this appointment?",
|
||||
"appointments.kalender.title": "Appointment calendar \u2014 Paliad",
|
||||
"appointments.kalender.heading": "Appointment calendar",
|
||||
"appointments.kalender.subtitle": "Monthly overview of all appointments.",
|
||||
"appointments.kalender.list": "List view",
|
||||
"appointments.kalender.empty": "No appointments in the selected period.",
|
||||
|
||||
// t-paliad-110 \u2014 unified Events page (rendered on /deadlines + /appointments).
|
||||
"events.toggle.deadline": "Deadlines",
|
||||
@@ -4394,7 +4572,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.view.cards": "Cards",
|
||||
"events.view.list": "List",
|
||||
"events.view.calendar": "Calendar",
|
||||
"events.calendar.empty": "No entries in the selected period.",
|
||||
"caldav.title": "CalDAV sync \u2014 Paliad",
|
||||
"caldav.heading": "CalDAV sync",
|
||||
"caldav.subtitle": "Sync your Paliad appointments with your external calendar (Nextcloud, iCloud, Outlook, mailcow\u2026). The password is stored encrypted and never returned.",
|
||||
@@ -4785,8 +4962,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.heading": "Team Management",
|
||||
"admin.team.subtitle": "View, edit and add Paliad accounts.",
|
||||
"admin.team.search.placeholder": "Search by name or email…",
|
||||
"admin.team.add.full": "Add account directly",
|
||||
"admin.team.add.direct": "Onboard existing account",
|
||||
"admin.team.add.invite": "Invite Colleague",
|
||||
"admin.team.add_full.title": "Add account directly",
|
||||
"admin.team.add_full.body": "Creates both the login account and the Paliad profile. The new colleague receives an email with a link to set a password.",
|
||||
"admin.team.add_full.email": "Email",
|
||||
"admin.team.add_full.name": "Display name",
|
||||
"admin.team.add_full.office": "Office",
|
||||
"admin.team.add_full.profession": "Profession",
|
||||
"admin.team.add_full.job_title": "Job title",
|
||||
"admin.team.add_full.lang": "Language",
|
||||
"admin.team.add_full.send_welcome": "Send welcome email with login link",
|
||||
"admin.team.add_full.cancel": "Cancel",
|
||||
"admin.team.add_full.submit": "Create",
|
||||
"admin.team.add_full.feedback.added": "Account created.",
|
||||
"admin.team.add_full.error.unavailable": "Add-User path is not configured (SUPABASE_SERVICE_ROLE_KEY missing on the server).",
|
||||
"admin.team.add_full.error.email_exists": "An account already exists for this email — please use 'Onboard existing account' instead.",
|
||||
"admin.team.add_full.error.generic": "Could not create the account.",
|
||||
"admin.team.loading": "Loading…",
|
||||
"admin.team.empty": "No matches.",
|
||||
"admin.team.error.forbidden": "Admins only.",
|
||||
|
||||
@@ -93,12 +93,13 @@ export function routeNameFor(pathname: string): string {
|
||||
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
|
||||
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
|
||||
if (pathname === "/deadlines/new") return "deadlines.new";
|
||||
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
|
||||
if (pathname === "/deadlines") return "deadlines.list";
|
||||
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
|
||||
if (pathname === "/appointments/new") return "appointments.new";
|
||||
if (pathname === "/appointments/calendar") return "appointments.calendar";
|
||||
if (pathname === "/appointments") return "appointments.list";
|
||||
// /deadlines/calendar + /appointments/calendar are 301 redirects to
|
||||
// /events?type=…&view=calendar since t-paliad-224 — the client never
|
||||
// sees those pathnames any more.
|
||||
if (pathname === "/agenda") return "agenda";
|
||||
if (pathname === "/inbox") return "inbox";
|
||||
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
|
||||
|
||||
@@ -1,525 +1,28 @@
|
||||
import { t, tDyn, type I18nKey, getLang } from "../i18n";
|
||||
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-calendar: month / week / day views. The view switcher is rendered
|
||||
// inline above the grid; the active view persists in the URL via
|
||||
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
|
||||
// shareable deep-link. Each view buckets the same flat ViewRow[] by
|
||||
// ISO-date — only the rendering differs.
|
||||
|
||||
type CalView = "month" | "week" | "day";
|
||||
|
||||
const VIEW_PARAM = "cal_view";
|
||||
const DATE_PARAM = "cal_date";
|
||||
const MAX_PILLS_PER_MONTH_CELL = 3;
|
||||
// shape-calendar — Custom Views calendar shape. Since t-paliad-224 this
|
||||
// is a thin adapter on top of the canonical mountCalendar() in
|
||||
// frontend/src/client/calendar/mount-calendar.ts. /events Kalender tab
|
||||
// uses the same module so both surfaces render identical DOM.
|
||||
// See docs/design-calendar-view-align-2026-05-20.md.
|
||||
|
||||
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const cfg = render.calendar ?? {};
|
||||
|
||||
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
|
||||
// screens). Documented in design §9 trade-off 8.
|
||||
if (window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const initialView = readView(cfg.default_view);
|
||||
const anchor = readAnchor(rows);
|
||||
paint(host, rows, anchor, initialView);
|
||||
}
|
||||
|
||||
// paint redraws the calendar in the supplied view + anchor. Called from
|
||||
// the view switcher and from the day/week navigation buttons. Each paint
|
||||
// clears the host so we don't leak prior DOM.
|
||||
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
|
||||
// Keep the mobile-notice (first child) if present; everything else is
|
||||
// re-rendered each time.
|
||||
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
|
||||
host.innerHTML = "";
|
||||
if (notice) host.appendChild(notice);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
|
||||
writeURL(nextView, nextAnchor);
|
||||
paint(host, rows, nextAnchor, nextView);
|
||||
}));
|
||||
|
||||
if (view === "month") {
|
||||
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
|
||||
writeURL("day", clickedDate);
|
||||
paint(host, rows, clickedDate, "day");
|
||||
}));
|
||||
} else if (view === "week") {
|
||||
wrap.appendChild(renderWeek(anchor, rows));
|
||||
} else {
|
||||
wrap.appendChild(renderDay(anchor, rows));
|
||||
}
|
||||
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
// --- Toolbar -------------------------------------------------------------
|
||||
|
||||
function renderToolbar(
|
||||
view: CalView,
|
||||
anchor: Date,
|
||||
onNav: (view: CalView, anchor: Date) => void,
|
||||
): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "views-calendar-toolbar";
|
||||
|
||||
// View switcher: month / week / day chips.
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "views-calendar-view-switcher agenda-chip-row";
|
||||
switcher.setAttribute("role", "tablist");
|
||||
for (const v of ["month", "week", "day"] as CalView[]) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
|
||||
chip.dataset.calView = v;
|
||||
chip.setAttribute("role", "tab");
|
||||
chip.setAttribute("aria-selected", v === view ? "true" : "false");
|
||||
chip.textContent = t(`cal.view.${v}` as I18nKey);
|
||||
chip.addEventListener("click", () => {
|
||||
if (v === view) return;
|
||||
onNav(v, anchor);
|
||||
});
|
||||
switcher.appendChild(chip);
|
||||
}
|
||||
bar.appendChild(switcher);
|
||||
|
||||
// Prev / current-label / next. Step size depends on the view.
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "views-calendar-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
|
||||
prev.textContent = "‹";
|
||||
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
|
||||
nav.appendChild(prev);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "views-calendar-nav-label";
|
||||
label.textContent = formatRangeLabel(view, anchor);
|
||||
nav.appendChild(label);
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
|
||||
next.textContent = "›";
|
||||
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
|
||||
nav.appendChild(next);
|
||||
|
||||
// Day/week view: provide a "Zurück zum Monat" link so users can climb
|
||||
// back without hunting for the switcher chip.
|
||||
if (view !== "month") {
|
||||
const backToMonth = document.createElement("button");
|
||||
backToMonth.type = "button";
|
||||
backToMonth.className = "btn-link views-calendar-back-to-month";
|
||||
backToMonth.textContent = t("cal.day.back_to_month");
|
||||
backToMonth.addEventListener("click", () => onNav("month", anchor));
|
||||
nav.appendChild(backToMonth);
|
||||
}
|
||||
|
||||
bar.appendChild(nav);
|
||||
return bar;
|
||||
}
|
||||
|
||||
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
|
||||
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
|
||||
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
|
||||
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
|
||||
}
|
||||
|
||||
// --- Month view ----------------------------------------------------------
|
||||
|
||||
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
// Single grid with one column-template that the weekday row and the day
|
||||
// cells share. The header row is added with `grid-column: span 7` so
|
||||
// it spans the full width above the day grid (laid out below).
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const weekdayKeys: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
// Pad start with prev-month spillover.
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
|
||||
const byDate = bucketByDate(rows, (d) =>
|
||||
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
|
||||
);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
|
||||
const dateKey = isoDate(dayDate);
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderMonthCell(
|
||||
dayDate: Date,
|
||||
dayNum: number,
|
||||
dayRows: ViewRow[],
|
||||
onDayDrill: (d: Date) => void,
|
||||
): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
|
||||
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
|
||||
|
||||
// Day-number is a click-target that switches to the day view. We render
|
||||
// it as a button to keep keyboard semantics; the surrounding cell stays
|
||||
// a div so it doesn't compete with the inner row anchors.
|
||||
const dayLabel = document.createElement("button");
|
||||
dayLabel.type = "button";
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(dayNum);
|
||||
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
dayLabel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
|
||||
for (const row of visible) {
|
||||
ul.appendChild(renderPill(row));
|
||||
}
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
const moreBtn = document.createElement("button");
|
||||
moreBtn.type = "button";
|
||||
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
|
||||
moreBtn.textContent = `+${dayRows.length - visible.length}`;
|
||||
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
moreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
more.appendChild(moreBtn);
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
// --- Week view -----------------------------------------------------------
|
||||
|
||||
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-week";
|
||||
|
||||
const weekStart = startOfWeek(anchor);
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-week-grid";
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
const col = renderWeekColumn(day, rows);
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const col = document.createElement("div");
|
||||
col.className = "views-calendar-week-column";
|
||||
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-calendar-week-head";
|
||||
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
|
||||
const dow = document.createElement("span");
|
||||
dow.className = "views-calendar-week-dow";
|
||||
dow.textContent = t(weekdayKey);
|
||||
const dnum = document.createElement("span");
|
||||
dnum.className = "views-calendar-week-dnum";
|
||||
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
head.appendChild(dow);
|
||||
head.appendChild(dnum);
|
||||
col.appendChild(head);
|
||||
|
||||
// No 3-row cap on week / day views — show everything for that day.
|
||||
const dayRows = filterByDay(rows, day);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-week-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
col.appendChild(empty);
|
||||
return col;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-week-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "week"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
col.appendChild(ul);
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- Day view ------------------------------------------------------------
|
||||
|
||||
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-day-wrap";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const dayRows = filterByDay(rows, anchor);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-day-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-day-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "day"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// --- Row rendering -------------------------------------------------------
|
||||
|
||||
function renderPill(row: ViewRow): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
a.textContent = row.title;
|
||||
a.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
// Pills are anchors — month-cell day-button click ignores them via
|
||||
// stopPropagation on the button; cell-level handlers would intercept
|
||||
// them otherwise.
|
||||
a.addEventListener("click", (e) => e.stopPropagation());
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
|
||||
a.appendChild(dot);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "views-calendar-row-body";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-calendar-row-title";
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(tDyn("views.kind." + row.kind));
|
||||
if (row.project_reference) metaParts.push(row.project_reference);
|
||||
else if (row.project_title) metaParts.push(row.project_title);
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "views-calendar-row-meta";
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
a.appendChild(body);
|
||||
return a;
|
||||
}
|
||||
|
||||
function rowHref(row: ViewRow): string {
|
||||
switch (row.kind) {
|
||||
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
|
||||
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
|
||||
case "approval_request": return `/inbox`;
|
||||
case "project_event":
|
||||
// project_events surface on the project's Verlauf — best we can do
|
||||
// is link to the project. If no project, leave as a non-link target.
|
||||
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bucketing / date helpers --------------------------------------------
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
|
||||
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
|
||||
const out = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (!filter(d)) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = out.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else out.set(key, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
|
||||
const key = isoDate(day);
|
||||
return rows.filter((r) => {
|
||||
const d = new Date(r.event_date);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
return isoDate(d) === key;
|
||||
const items: CalendarItem[] = rows.map(toCalendarItem);
|
||||
mountCalendar(host, items, {
|
||||
defaultView: render.calendar?.default_view ?? "month",
|
||||
urlState: true,
|
||||
});
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const offset = (out.getDay() + 6) % 7; // Mon=0
|
||||
out.setDate(out.getDate() - offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
function shift(d: Date, view: CalView, dir: number): Date {
|
||||
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||||
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatRangeLabel(view: CalView, anchor: Date): string {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (view === "month") {
|
||||
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(anchor);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return formatWeekHeader(start, end, lang);
|
||||
}
|
||||
return anchor.toLocaleDateString(lang, {
|
||||
weekday: "short", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekHeader(start: Date, end: Date, lang: string): string {
|
||||
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
// --- URL state -----------------------------------------------------------
|
||||
|
||||
function readView(defaultView: CalView | undefined): CalView {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(VIEW_PARAM);
|
||||
if (raw === "month" || raw === "week" || raw === "day") return raw;
|
||||
return defaultView ?? "month";
|
||||
}
|
||||
|
||||
function readAnchor(rows: ViewRow[]): Date {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(DATE_PARAM);
|
||||
if (raw) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
// No URL anchor — pick the first row's date, or today.
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function writeURL(view: CalView, anchor: Date): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(VIEW_PARAM, view);
|
||||
url.searchParams.set(DATE_PARAM, isoDate(anchor));
|
||||
history.replaceState(null, "", url.toString());
|
||||
function toCalendarItem(row: ViewRow): CalendarItem {
|
||||
return {
|
||||
kind: row.kind,
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
event_date: row.event_date,
|
||||
project_id: row.project_id,
|
||||
project_title: row.project_title,
|
||||
project_reference: row.project_reference,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderDeadlinesCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="deadlines.kalender.title">Fristenkalender — 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ü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">←</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ächster Monat">→</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ä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">×</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>
|
||||
);
|
||||
}
|
||||
@@ -236,37 +236,10 @@ export function renderEvents(): string {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</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ächster Monat">→</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äge im ausgewä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">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Calendar host — mountCalendar() (t-paliad-224) builds the
|
||||
month/week/day grid + toolbar into this container when
|
||||
the Kalender view chip is active. Empty until then. */}
|
||||
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden />
|
||||
|
||||
<div className="entity-empty" id="events-empty" style="display:none">
|
||||
<h2 data-i18n="events.empty.title">Keine Einträge vorhanden</h2>
|
||||
|
||||
@@ -440,7 +440,23 @@ export type I18nKey =
|
||||
| "admin.section.planned"
|
||||
| "admin.subtitle"
|
||||
| "admin.team.add.direct"
|
||||
| "admin.team.add.full"
|
||||
| "admin.team.add.invite"
|
||||
| "admin.team.add_full.body"
|
||||
| "admin.team.add_full.cancel"
|
||||
| "admin.team.add_full.email"
|
||||
| "admin.team.add_full.error.email_exists"
|
||||
| "admin.team.add_full.error.generic"
|
||||
| "admin.team.add_full.error.unavailable"
|
||||
| "admin.team.add_full.feedback.added"
|
||||
| "admin.team.add_full.job_title"
|
||||
| "admin.team.add_full.lang"
|
||||
| "admin.team.add_full.name"
|
||||
| "admin.team.add_full.office"
|
||||
| "admin.team.add_full.profession"
|
||||
| "admin.team.add_full.send_welcome"
|
||||
| "admin.team.add_full.submit"
|
||||
| "admin.team.add_full.title"
|
||||
| "admin.team.col.actions"
|
||||
| "admin.team.col.additional"
|
||||
| "admin.team.col.created"
|
||||
@@ -555,12 +571,6 @@ export type I18nKey =
|
||||
| "appointments.filter.type"
|
||||
| "appointments.filter.type.all"
|
||||
| "appointments.form.approval_hint"
|
||||
| "appointments.kalender.empty"
|
||||
| "appointments.kalender.heading"
|
||||
| "appointments.kalender.list"
|
||||
| "appointments.kalender.subtitle"
|
||||
| "appointments.kalender.title"
|
||||
| "appointments.list.calendar"
|
||||
| "appointments.list.heading"
|
||||
| "appointments.list.new"
|
||||
| "appointments.list.subtitle"
|
||||
@@ -705,6 +715,7 @@ export type I18nKey =
|
||||
| "cal.month.9"
|
||||
| "cal.month.next"
|
||||
| "cal.month.prev"
|
||||
| "cal.today"
|
||||
| "cal.view.day"
|
||||
| "cal.view.month"
|
||||
| "cal.view.week"
|
||||
@@ -789,7 +800,54 @@ export type I18nKey =
|
||||
| "changelog.tag.feature"
|
||||
| "changelog.tag.fix"
|
||||
| "changelog.title"
|
||||
| "checklisten.author.cancel"
|
||||
| "checklisten.author.error.generic"
|
||||
| "checklisten.author.error.no_groups"
|
||||
| "checklisten.author.error.notfound"
|
||||
| "checklisten.author.error.title"
|
||||
| "checklisten.author.field.court"
|
||||
| "checklisten.author.field.deadline"
|
||||
| "checklisten.author.field.description"
|
||||
| "checklisten.author.field.lang"
|
||||
| "checklisten.author.field.reference"
|
||||
| "checklisten.author.field.regime"
|
||||
| "checklisten.author.field.title"
|
||||
| "checklisten.author.field.title.hint"
|
||||
| "checklisten.author.field.visibility"
|
||||
| "checklisten.author.group.remove"
|
||||
| "checklisten.author.group.title"
|
||||
| "checklisten.author.groups.add"
|
||||
| "checklisten.author.groups.heading"
|
||||
| "checklisten.author.heading.edit"
|
||||
| "checklisten.author.heading.new"
|
||||
| "checklisten.author.item.add"
|
||||
| "checklisten.author.item.label"
|
||||
| "checklisten.author.item.note"
|
||||
| "checklisten.author.item.remove"
|
||||
| "checklisten.author.item.rule"
|
||||
| "checklisten.author.save"
|
||||
| "checklisten.author.saving"
|
||||
| "checklisten.author.subtitle"
|
||||
| "checklisten.author.title"
|
||||
| "checklisten.author.title.edit"
|
||||
| "checklisten.author.visibility.firm.hint"
|
||||
| "checklisten.author.visibility.private.hint"
|
||||
| "checklisten.back"
|
||||
| "checklisten.detail.authored.by"
|
||||
| "checklisten.detail.delete"
|
||||
| "checklisten.detail.delete.confirm"
|
||||
| "checklisten.detail.delete.error"
|
||||
| "checklisten.detail.demote"
|
||||
| "checklisten.detail.demote.confirm"
|
||||
| "checklisten.detail.edit"
|
||||
| "checklisten.detail.promote"
|
||||
| "checklisten.detail.promote.confirm"
|
||||
| "checklisten.detail.promote.error"
|
||||
| "checklisten.detail.share"
|
||||
| "checklisten.detail.visibility"
|
||||
| "checklisten.detail.visibility.error"
|
||||
| "checklisten.detail.visibility.set.firm"
|
||||
| "checklisten.detail.visibility.set.private"
|
||||
| "checklisten.disclaimer"
|
||||
| "checklisten.empty"
|
||||
| "checklisten.feedback.btn"
|
||||
@@ -807,11 +865,23 @@ export type I18nKey =
|
||||
| "checklisten.feedback.type"
|
||||
| "checklisten.filter.all"
|
||||
| "checklisten.filter.de"
|
||||
| "checklisten.filter.other"
|
||||
| "checklisten.gallery.empty"
|
||||
| "checklisten.heading"
|
||||
| "checklisten.instance.akte.open"
|
||||
| "checklisten.instance.back"
|
||||
| "checklisten.instance.diff.added"
|
||||
| "checklisten.instance.diff.changed"
|
||||
| "checklisten.instance.diff.close"
|
||||
| "checklisten.instance.diff.empty"
|
||||
| "checklisten.instance.diff.error"
|
||||
| "checklisten.instance.diff.removed"
|
||||
| "checklisten.instance.diff.title"
|
||||
| "checklisten.instance.loading"
|
||||
| "checklisten.instance.notfound"
|
||||
| "checklisten.instance.outdated.badge"
|
||||
| "checklisten.instance.outdated.diff"
|
||||
| "checklisten.instance.outdated.note"
|
||||
| "checklisten.instance.rename"
|
||||
| "checklisten.instance.rename.error"
|
||||
| "checklisten.instance.rename.save"
|
||||
@@ -834,6 +904,18 @@ export type I18nKey =
|
||||
| "checklisten.instances.heading"
|
||||
| "checklisten.instances.loading"
|
||||
| "checklisten.instances.sub"
|
||||
| "checklisten.mine.delete"
|
||||
| "checklisten.mine.delete.confirm"
|
||||
| "checklisten.mine.delete.error"
|
||||
| "checklisten.mine.edit"
|
||||
| "checklisten.mine.empty"
|
||||
| "checklisten.mine.loading"
|
||||
| "checklisten.mine.new"
|
||||
| "checklisten.mine.origin.authored"
|
||||
| "checklisten.mine.visibility.firm"
|
||||
| "checklisten.mine.visibility.global"
|
||||
| "checklisten.mine.visibility.private"
|
||||
| "checklisten.mine.visibility.shared"
|
||||
| "checklisten.newInstance"
|
||||
| "checklisten.newInstance.akte"
|
||||
| "checklisten.newInstance.akte.hint"
|
||||
@@ -850,8 +932,31 @@ export type I18nKey =
|
||||
| "checklisten.reset"
|
||||
| "checklisten.reset.confirm"
|
||||
| "checklisten.reset.error"
|
||||
| "checklisten.share.cancel"
|
||||
| "checklisten.share.error.generic"
|
||||
| "checklisten.share.error.pick"
|
||||
| "checklisten.share.grants.empty"
|
||||
| "checklisten.share.grants.heading"
|
||||
| "checklisten.share.grants.recipient.office"
|
||||
| "checklisten.share.grants.recipient.partner_unit"
|
||||
| "checklisten.share.grants.recipient.project"
|
||||
| "checklisten.share.grants.recipient.user"
|
||||
| "checklisten.share.grants.revoke"
|
||||
| "checklisten.share.grants.revoke.confirm"
|
||||
| "checklisten.share.grants.revoke.error"
|
||||
| "checklisten.share.kind"
|
||||
| "checklisten.share.kind.office"
|
||||
| "checklisten.share.kind.partner_unit"
|
||||
| "checklisten.share.kind.project"
|
||||
| "checklisten.share.kind.user"
|
||||
| "checklisten.share.pick"
|
||||
| "checklisten.share.submit"
|
||||
| "checklisten.share.success"
|
||||
| "checklisten.share.title"
|
||||
| "checklisten.subtitle"
|
||||
| "checklisten.tab.gallery"
|
||||
| "checklisten.tab.instances"
|
||||
| "checklisten.tab.mine"
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "common.cancel"
|
||||
@@ -1120,13 +1225,6 @@ export type I18nKey =
|
||||
| "deadlines.inbox.label"
|
||||
| "deadlines.inbox.posteingang"
|
||||
| "deadlines.inbox.posteingang.title"
|
||||
| "deadlines.kalender.empty"
|
||||
| "deadlines.kalender.heading"
|
||||
| "deadlines.kalender.list"
|
||||
| "deadlines.kalender.subtitle"
|
||||
| "deadlines.kalender.title"
|
||||
| "deadlines.kalender.today"
|
||||
| "deadlines.list.calendar"
|
||||
| "deadlines.list.heading"
|
||||
| "deadlines.list.new"
|
||||
| "deadlines.list.subtitle"
|
||||
@@ -1454,7 +1552,6 @@ export type I18nKey =
|
||||
| "event_types.picker.no_match"
|
||||
| "event_types.picker.remove"
|
||||
| "event_types.picker.search"
|
||||
| "events.calendar.empty"
|
||||
| "events.col.appointment_type"
|
||||
| "events.col.date"
|
||||
| "events.col.location"
|
||||
|
||||
@@ -7470,158 +7470,10 @@ dialog.modal::backdrop {
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
/* Calendar view */
|
||||
|
||||
.frist-calendar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.frist-cal-month-label {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
min-width: 11rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.frist-calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--color-border);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frist-cal-weekday {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.frist-cal-grid {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.frist-cal-cell {
|
||||
background: var(--color-surface);
|
||||
min-height: 88px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.frist-cal-cell-empty {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.frist-cal-cell-has {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.frist-cal-cell-has:hover {
|
||||
background: var(--color-bg-lime-tint);
|
||||
}
|
||||
|
||||
.frist-cal-day {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.frist-cal-today .frist-cal-day {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
border-radius: 999px;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.frist-cal-dots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.frist-cal-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--frist-grey);
|
||||
}
|
||||
|
||||
.frist-cal-dot.frist-urgency-overdue { background: var(--frist-red); }
|
||||
.frist-cal-dot.frist-urgency-soon { background: var(--frist-amber); }
|
||||
.frist-cal-dot.frist-urgency-later { background: var(--frist-green); }
|
||||
.frist-cal-dot.frist-urgency-done { background: var(--frist-grey); }
|
||||
|
||||
.frist-cal-more {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
.frist-cal-popup-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.frist-cal-popup-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.frist-cal-popup-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.frist-cal-popup-title {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.frist-cal-popup-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.frist-cal-popup-akte {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.frist-cal-popup-akte:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* Calendar view styles live in .views-calendar-* (search for that
|
||||
prefix). The /events Kalender tab and Custom Views shape=calendar
|
||||
both mount the same component (frontend/src/client/calendar/
|
||||
mount-calendar.ts, t-paliad-224). */
|
||||
|
||||
/* Fristenrechner save-to-Akte modal */
|
||||
|
||||
@@ -8027,9 +7879,6 @@ dialog.modal::backdrop {
|
||||
.frist-summary-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.frist-cal-cell {
|
||||
min-height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
@@ -8688,27 +8537,6 @@ dialog.modal::backdrop {
|
||||
.termin-card-week .frist-summary-dot { background: #2563eb; }
|
||||
.termin-card-later .frist-summary-dot { background: #475569; }
|
||||
|
||||
.termin-cal-legend {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.5rem 0 1rem;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.termin-cal-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Calendar popup: extra time column for termine (vs. the deadline popup). */
|
||||
.frist-cal-popup-time {
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* CalDAV settings page */
|
||||
.caldav-status-card {
|
||||
background: var(--color-surface-muted);
|
||||
@@ -11527,18 +11355,13 @@ dialog.quick-add-sheet::backdrop {
|
||||
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
/* Calendar view (t-paliad-115). Reuses the existing .frist-calendar
|
||||
styles — only the appointment dot colour is new. The frist-cal-dot
|
||||
urgency variants already cover the deadline palette; we just need a
|
||||
distinct hue for appointments so a mixed-type cell reads at a glance. */
|
||||
/* Calendar host — mountCalendar() (t-paliad-224) builds the toolbar +
|
||||
grid into this wrapper when the user picks the Kalender chip. All
|
||||
internal styling lives in .views-calendar-* (search for that prefix). */
|
||||
.events-calendar-wrap {
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.frist-cal-dot.events-cal-dot-appointment {
|
||||
background: var(--bucket-next-week, #1d4ed8);
|
||||
}
|
||||
|
||||
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
|
||||
.event-type-add-modal {
|
||||
width: 28rem;
|
||||
|
||||
13
internal/db/migrations/114_user_checklists.down.sql
Normal file
13
internal/db/migrations/114_user_checklists.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Reverse of mig 114 — t-paliad-225 / m/paliad#61 Slice A.
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
DROP COLUMN IF EXISTS template_snapshot;
|
||||
|
||||
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
|
||||
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
|
||||
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
|
||||
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_checklist(uuid, uuid);
|
||||
|
||||
DROP TABLE IF EXISTS paliad.checklists;
|
||||
178
internal/db/migrations/114_user_checklists.up.sql
Normal file
178
internal/db/migrations/114_user_checklists.up.sql
Normal file
@@ -0,0 +1,178 @@
|
||||
-- mig 114 — t-paliad-225 / m/paliad#61 Slice A — user-authored checklists.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md
|
||||
--
|
||||
-- Introduces paliad.checklists (the authored-template catalog), the
|
||||
-- paliad.can_see_checklist(uuid, uuid) visibility predicate, and a
|
||||
-- nullable template_snapshot column on paliad.checklist_instances so
|
||||
-- per-Akte instances stay decoupled from subsequent template edits.
|
||||
--
|
||||
-- Slice A ships with private + firm visibility only; the 'shared' and
|
||||
-- 'global' values are valid in the CHECK enum so Slice B can add the
|
||||
-- explicit-share path and admin-promotion without a second migration
|
||||
-- to the enum.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE TABLE paliad.checklists.
|
||||
-- 2. paliad.can_see_checklist(uuid, uuid) predicate.
|
||||
-- 3. RLS policies on paliad.checklists.
|
||||
-- 4. ALTER TABLE paliad.checklist_instances ADD COLUMN template_snapshot.
|
||||
--
|
||||
-- Idempotent throughout (CREATE … IF NOT EXISTS / CREATE OR REPLACE
|
||||
-- FUNCTION / DROP POLICY IF EXISTS + CREATE POLICY).
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.checklists — authored-template catalog.
|
||||
--
|
||||
-- The static Go catalog (internal/checklists/templates.go) stays the
|
||||
-- firm's curated source for legally-reviewed templates. This table holds
|
||||
-- user-authored templates that augment that catalog at read time via
|
||||
-- ChecklistCatalogService.
|
||||
--
|
||||
-- Slugs are author-facing and unique within this table. The application
|
||||
-- layer rejects slugs that collide with the static catalog (see
|
||||
-- ChecklistTemplateService.Create — applies a 'u-' prefix and falls back
|
||||
-- through a collision-retry loop).
|
||||
--
|
||||
-- body jsonb carries { "groups": [{ "title", "items": [{ "label", "note",
|
||||
-- "rule" }] }] } — the same shape as the static checklists.Template
|
||||
-- minus the metadata (which lives in dedicated columns).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.checklists (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
regime text NOT NULL DEFAULT 'OTHER',
|
||||
court text NOT NULL DEFAULT '',
|
||||
reference text NOT NULL DEFAULT '',
|
||||
deadline text NOT NULL DEFAULT '',
|
||||
lang text NOT NULL DEFAULT 'de',
|
||||
body jsonb NOT NULL,
|
||||
visibility text NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
|
||||
promoted_at timestamptz,
|
||||
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS checklists_owner_idx
|
||||
ON paliad.checklists (owner_id);
|
||||
CREATE INDEX IF NOT EXISTS checklists_visibility_idx
|
||||
ON paliad.checklists (visibility)
|
||||
WHERE visibility IN ('firm', 'global');
|
||||
CREATE INDEX IF NOT EXISTS checklists_regime_idx
|
||||
ON paliad.checklists (regime);
|
||||
|
||||
COMMENT ON TABLE paliad.checklists IS
|
||||
'User-authored checklist templates. Augments the static Go catalog '
|
||||
'at read time via ChecklistCatalogService. Visibility levels: '
|
||||
'private (owner only), shared (Slice B), firm (all authenticated), '
|
||||
'global (admin-promoted into firm catalog — Slice B).';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.can_see_checklist(_user_id, _checklist_id)
|
||||
--
|
||||
-- Pattern mirrors paliad.can_see_project / paliad.effective_project_admin
|
||||
-- (mig 111): STABLE SECURITY DEFINER, single-statement, predicate-friendly.
|
||||
--
|
||||
-- Slice A only relies on the owner + firm/global branches. The shared
|
||||
-- branch (matching against paliad.checklist_shares) is wired now so
|
||||
-- Slice B doesn't need to replace the function — a NULL row count just
|
||||
-- returns false. The table doesn't exist yet, so the EXISTS clause must
|
||||
-- be guarded; we inline a NOT EXISTS check on pg_class so the function
|
||||
-- body compiles cleanly on Slice A while staying ready for Slice B.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner can always see.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.owner_id = _user_id
|
||||
)
|
||||
-- firm / global visibility: every authenticated user.
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.visibility IN ('firm', 'global')
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
|
||||
'True iff the user owns the checklist OR the checklist visibility is '
|
||||
'firm/global. Slice B extends this predicate with the explicit-share '
|
||||
'path over paliad.checklist_shares.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS on paliad.checklists.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: owner OR visible via can_see_checklist.
|
||||
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
|
||||
CREATE POLICY checklists_select
|
||||
ON paliad.checklists FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_checklist(auth.uid(), id));
|
||||
|
||||
-- INSERT: caller can only create templates owned by themselves.
|
||||
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
|
||||
CREATE POLICY checklists_insert
|
||||
ON paliad.checklists FOR INSERT TO authenticated
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- UPDATE: owner OR global_admin.
|
||||
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
|
||||
CREATE POLICY checklists_update
|
||||
ON paliad.checklists FOR UPDATE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin.
|
||||
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
|
||||
CREATE POLICY checklists_delete
|
||||
ON paliad.checklists FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. paliad.checklist_instances.template_snapshot — instance integrity column.
|
||||
--
|
||||
-- Captures the template body (groups + items) at instance create time so
|
||||
-- subsequent template edits / visibility narrowing don't affect existing
|
||||
-- per-Akte instances. NULL on rows created before this migration; the
|
||||
-- service layer falls back to live catalog lookup for those.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklist_instances.template_snapshot IS
|
||||
'Snapshot of the template body at instance create time. NULL for '
|
||||
'pre-mig-114 rows; service layer falls back to live catalog lookup '
|
||||
'in that case (legacy path; backfilled in Slice C).';
|
||||
26
internal/db/migrations/115_checklist_shares.down.sql
Normal file
26
internal/db/migrations/115_checklist_shares.down.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Reverse of mig 115 — t-paliad-225 / m/paliad#61 Slice B.
|
||||
--
|
||||
-- Restore the owner+firm/global-only body of paliad.can_see_checklist
|
||||
-- (matches the mig 114 definition) so a rollback of Slice B leaves the
|
||||
-- function pointing at the Slice A behaviour.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.owner_id = _user_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
|
||||
);
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
|
||||
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
|
||||
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.checklist_shares;
|
||||
211
internal/db/migrations/115_checklist_shares.up.sql
Normal file
211
internal/db/migrations/115_checklist_shares.up.sql
Normal file
@@ -0,0 +1,211 @@
|
||||
-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing +
|
||||
-- admin-promotion plumbing for user-authored checklists.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3
|
||||
-- / §4.5.
|
||||
--
|
||||
-- Introduces paliad.checklist_shares with the polymorphic recipient
|
||||
-- pattern (xor-check enforces exactly one recipient_* column populated
|
||||
-- per recipient_kind). Extends paliad.can_see_checklist with the
|
||||
-- explicit-share branches so the 'shared' visibility level actually
|
||||
-- gates anything.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS).
|
||||
-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share
|
||||
-- branches (user / office / partner_unit / project).
|
||||
--
|
||||
-- Idempotent throughout.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.checklist_shares — explicit grants for a single checklist.
|
||||
--
|
||||
-- recipient_kind disambiguates which recipient_* column is populated.
|
||||
-- The XOR check makes the constraint structurally enforce "exactly one
|
||||
-- recipient_<kind> non-null per row". Per-kind UNIQUE partial indexes
|
||||
-- prevent duplicate grants per (checklist, recipient).
|
||||
--
|
||||
-- Slice A's checklists.visibility CHECK already includes 'shared' so no
|
||||
-- ALTER is needed here.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.checklist_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
|
||||
recipient_kind text NOT NULL
|
||||
CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
|
||||
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
recipient_office text,
|
||||
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT checklist_shares_recipient_xor CHECK (
|
||||
(recipient_kind = 'user'
|
||||
AND recipient_user_id IS NOT NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_partner_unit_id IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'office'
|
||||
AND recipient_office IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_partner_unit_id IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'partner_unit'
|
||||
AND recipient_partner_unit_id IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'project'
|
||||
AND recipient_project_id IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_partner_unit_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Hot-path lookup for the visibility predicate.
|
||||
CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx
|
||||
ON paliad.checklist_shares (checklist_id);
|
||||
|
||||
-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_<other>
|
||||
-- doesn't collide with another row's NULL recipient_<other>.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_user_id)
|
||||
WHERE recipient_kind = 'user';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_office)
|
||||
WHERE recipient_kind = 'office';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id)
|
||||
WHERE recipient_kind = 'partner_unit';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_project_id)
|
||||
WHERE recipient_kind = 'project';
|
||||
|
||||
COMMENT ON TABLE paliad.checklist_shares IS
|
||||
'Explicit grants for paliad.checklists. Polymorphic recipient '
|
||||
'(user/office/partner_unit/project) enforced by recipient_xor CHECK. '
|
||||
'Owner of the checklist grants and revokes; global_admin can revoke '
|
||||
'as well. Slice B (t-paliad-225) — see can_see_checklist body for '
|
||||
'the visibility branches that consume these rows.';
|
||||
|
||||
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: caller can see the row if they own the parent checklist OR
|
||||
-- they are the recipient (for user-kind grants — recipients shouldn't
|
||||
-- be surprised by who else can also see the checklist) OR global_admin.
|
||||
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_select
|
||||
ON paliad.checklist_shares FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: only the checklist owner can grant; granted_by must be self.
|
||||
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_insert
|
||||
ON paliad.checklist_shares FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
AND granted_by = auth.uid()
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin. No UPDATE policy — grants are
|
||||
-- immutable, revoke = DELETE + re-insert with the corrected recipient.
|
||||
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_delete
|
||||
ON paliad.checklist_shares FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.can_see_checklist — extend with the 4 share branches.
|
||||
--
|
||||
-- Owner + firm/global branches stay as in mig 114. Share branches:
|
||||
-- - user — the row's recipient_user_id matches the caller
|
||||
-- - office — recipient_office matches caller's office OR is in
|
||||
-- their additional_offices array
|
||||
-- - partner_unit — caller is a member of the recipient partner_unit
|
||||
-- - project — caller can see the recipient project (reuses
|
||||
-- paliad.can_see_project, ltree-walked)
|
||||
--
|
||||
-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance
|
||||
-- (same pattern effective_project_admin uses in mig 111).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.owner_id = _user_id
|
||||
)
|
||||
-- firm / global
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
|
||||
)
|
||||
-- Explicit share: user
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = _user_id
|
||||
)
|
||||
-- Explicit share: office (caller's primary OR additional offices)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
-- Explicit share: partner_unit (caller is a member)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
-- Explicit share: project (caller can see the project via existing predicate)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'project'
|
||||
AND paliad.can_see_project(s.recipient_project_id)
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
|
||||
'True iff the user owns the checklist OR firm/global visibility OR '
|
||||
'an explicit share row matches the caller (by user / office / '
|
||||
'partner_unit / project ancestry).';
|
||||
7
internal/db/migrations/116_checklist_versioning.down.sql
Normal file
7
internal/db/migrations/116_checklist_versioning.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Reverse of mig 116 — t-paliad-225 / m/paliad#61 Slice C.
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
DROP COLUMN IF EXISTS template_version;
|
||||
|
||||
ALTER TABLE paliad.checklists
|
||||
DROP COLUMN IF EXISTS version;
|
||||
39
internal/db/migrations/116_checklist_versioning.up.sql
Normal file
39
internal/db/migrations/116_checklist_versioning.up.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- mig 116 — t-paliad-225 / m/paliad#61 Slice C — template versioning.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md §3.4 / §6.
|
||||
--
|
||||
-- Adds an integer version counter to paliad.checklists that bumps on
|
||||
-- every meaningful edit (body or title — see
|
||||
-- ChecklistTemplateService.Update). Adds a matching template_version
|
||||
-- column on paliad.checklist_instances so the instance detail page can
|
||||
-- surface "the template you instantiated from has been updated" and
|
||||
-- offer a diff view.
|
||||
--
|
||||
-- Existing rows backfill to version=1 / template_version=NULL. The
|
||||
-- NULL on instances means "we don't know which version was snapshotted"
|
||||
-- (pre-Slice-C row); the snapshot column is still authoritative for
|
||||
-- rendering, but the "outdated" badge stays off because we can't
|
||||
-- compare.
|
||||
--
|
||||
-- Idempotent throughout.
|
||||
|
||||
ALTER TABLE paliad.checklists
|
||||
ADD COLUMN IF NOT EXISTS version int NOT NULL DEFAULT 1;
|
||||
|
||||
-- Backfill any rows that somehow ended up at 0 (shouldn't happen with
|
||||
-- DEFAULT 1, but defensive — the column was added with default so this
|
||||
-- is a no-op on the live DB).
|
||||
UPDATE paliad.checklists SET version = 1 WHERE version < 1;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklists.version IS
|
||||
'Monotonic version counter, bumps in ChecklistTemplateService.Update '
|
||||
'whenever body or title changes. Used by the instance detail page '
|
||||
'to show an "outdated" badge when the user''s snapshot is older.';
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_version int;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklist_instances.template_version IS
|
||||
'Snapshot of paliad.checklists.version at instance create time. '
|
||||
'NULL for pre-Slice-C rows where the version wasn''t captured; the '
|
||||
'"outdated" badge stays off in that case.';
|
||||
@@ -44,6 +44,78 @@ func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/admin/users/full — create BOTH an auth.users row (via Supabase
|
||||
// Admin API) and a paliad.users row in one operation. t-paliad-223 Slice B
|
||||
// (#49). Lets a global_admin onboard a colleague without forcing them
|
||||
// through the email-invitation round-trip; the new user is visible in
|
||||
// dropdowns immediately and can log in via the emailed magic-link.
|
||||
//
|
||||
// Requires SUPABASE_SERVICE_ROLE_KEY at the server. Returns 503 when
|
||||
// unset so a deploy that hasn't provisioned the credential yet gets a
|
||||
// clear diagnostic instead of a cryptic 500.
|
||||
//
|
||||
// Error mapping:
|
||||
// - ErrSupabaseAdminUnavailable → 503
|
||||
// - ErrSupabaseEmailExists → 409 (hint to use "Onboard existing")
|
||||
// - ErrUserAlreadyOnboarded → 409 (paliad.users dup; should be unreachable)
|
||||
// - ErrInvalidInput → 400 (bad shape)
|
||||
// - email domain not on whitelist → 403 (mirrors handleAdminCreateUser)
|
||||
// - other → 500
|
||||
func handleAdminCreateFullUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.AdminCreateFullInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if !isAllowedEmailDomain(input.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "email domain not on the " + branding.Name + " allow-list",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the inviter (the calling admin) so the welcome email and
|
||||
// audit row carry their identity. Failures here shouldn't block the
|
||||
// create; we just degrade to empty fields.
|
||||
inviter, err := dbSvc.users.GetByID(r.Context(), uid)
|
||||
if err == nil && inviter != nil {
|
||||
input.InviterID = inviter.ID
|
||||
input.InviterName = inviter.DisplayName
|
||||
input.InviterEmail = inviter.Email
|
||||
}
|
||||
|
||||
u, err := dbSvc.users.AdminCreateUserFull(r.Context(), input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrSupabaseAdminUnavailable):
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "add-user flow requires SUPABASE_SERVICE_ROLE_KEY on the server",
|
||||
})
|
||||
case errors.Is(err, services.ErrSupabaseEmailExists):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "auth account already exists — please use 'Onboard existing' instead",
|
||||
})
|
||||
case errors.Is(err, services.ErrUserAlreadyOnboarded):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "user already onboarded",
|
||||
})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
|
||||
// POST /api/admin/users — direct-create a paliad.users row for an existing
|
||||
// auth.users entry. The recipient email's domain must already match the
|
||||
// allowed-email policy (Supabase wouldn't have let them sign up otherwise),
|
||||
|
||||
@@ -24,8 +24,13 @@ func handleAppointmentsDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/appointments-detail.html")
|
||||
}
|
||||
|
||||
// handleAppointmentsCalendarPage 301-redirects the legacy standalone
|
||||
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
|
||||
// m/paliad#55). Counterpart of handleDeadlinesCalendarPage — same
|
||||
// reasoning: the standalone page was orphaned in navigation since
|
||||
// t-paliad-110, the canonical calendar lives inside /events.
|
||||
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/appointments-calendar.html")
|
||||
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// handleSettingsPage serves the unified settings page with tabs for
|
||||
|
||||
131
internal/handlers/checklist_shares.go
Normal file
131
internal/handlers/checklist_shares.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/checklists/templates/{slug}/shares — list grants (owner/admin).
|
||||
func handleListChecklistShares(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
rows, err := dbSvc.checklistShare.ListGrants(r.Context(), uid, slug)
|
||||
if err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/checklists/templates/{slug}/shares — grant a share.
|
||||
func handleGrantChecklistShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var input services.ShareGrantInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
share, err := dbSvc.checklistShare.Grant(r.Context(), uid, slug, input)
|
||||
if err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
// DELETE /api/checklists/shares/{id} — revoke a share by id.
|
||||
func handleRevokeChecklistShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.checklistShare.Revoke(r.Context(), uid, id); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/admin/checklists/{slug}/promote — global_admin only.
|
||||
func handlePromoteChecklist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
if err := dbSvc.checklistPromotion.Promote(r.Context(), uid, slug); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/admin/checklists/{slug}/demote — global_admin only.
|
||||
func handleDemoteChecklist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
// Body is optional — Demote defaults to 'firm' when empty.
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if err := dbSvc.checklistPromotion.Demote(r.Context(), uid, slug, body.Target); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeChecklistShareError maps the share/promotion service errors.
|
||||
// Same as the templates handler: ErrInvalidInput → 400, ErrForbidden →
|
||||
// 403, ErrNotVisible → 404, fall through to writeServiceError.
|
||||
func writeChecklistShareError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrNotVisible) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
133
internal/handlers/checklist_templates.go
Normal file
133
internal/handlers/checklist_templates.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/checklists/templates/mine — list authored templates owned by caller.
|
||||
func handleListMyChecklistTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.checklistTemplate.ListOwnedBy(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/checklists/templates — create a new authored template.
|
||||
func handleCreateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
t, err := dbSvc.checklistTemplate.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// PATCH /api/checklists/templates/{slug} — update authored template (owner only).
|
||||
func handleUpdateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var input services.UpdateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
t, err := dbSvc.checklistTemplate.Update(r.Context(), uid, slug, input)
|
||||
if err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// PATCH /api/checklists/templates/{slug}/visibility — toggle private↔firm.
|
||||
func handleSetChecklistTemplateVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var body struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
t, err := dbSvc.checklistTemplate.SetVisibility(r.Context(), uid, slug, body.Visibility)
|
||||
if err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// DELETE /api/checklists/templates/{slug} — delete authored template.
|
||||
func handleDeleteChecklistTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
if err := dbSvc.checklistTemplate.Delete(r.Context(), uid, slug); err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeChecklistTemplateError maps service errors to HTTP status. Falls
|
||||
// through to writeServiceError for unknown errors so the generic
|
||||
// ErrNotVisible / ErrInvalidInput / ErrForbidden mappings still apply.
|
||||
func writeChecklistTemplateError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": strings.TrimPrefix(err.Error(), "invalid input: ")})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrNotVisible) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
@@ -24,6 +24,13 @@ func handleChecklistsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/checklists.html")
|
||||
}
|
||||
|
||||
// handleChecklistsAuthorPage serves the authoring wizard (new + edit
|
||||
// share the same bundle; the client reads location.pathname to decide
|
||||
// create vs edit mode).
|
||||
func handleChecklistsAuthorPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/checklists-author.html")
|
||||
}
|
||||
|
||||
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if _, ok := checklists.Find(slug); !ok {
|
||||
@@ -37,18 +44,105 @@ func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/checklists-instance.html")
|
||||
}
|
||||
|
||||
// handleChecklistsAPI returns the merged catalog: static templates
|
||||
// (always) plus authored DB templates the caller can see (mig 114).
|
||||
// Each entry carries origin + visibility + author metadata so the
|
||||
// frontend can render provenance.
|
||||
//
|
||||
// Falls back to the bare static catalog when DB is unavailable so the
|
||||
// knowledge-platform-only deploy stays functional without DATABASE_URL.
|
||||
func handleChecklistsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, checklists.Summaries())
|
||||
if dbSvc == nil || dbSvc.checklistCatalog == nil {
|
||||
// Fall back to static summaries shape so the existing frontend
|
||||
// keeps working in the no-DB deploy.
|
||||
writeJSON(w, http.StatusOK, checklists.Summaries())
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := dbSvc.checklistCatalog.ListVisible(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
// Frontend expects the existing Summary shape on the index list; map
|
||||
// the merged entries to Summary + origin/visibility/author fields.
|
||||
type Summary struct {
|
||||
checklists.Summary
|
||||
Origin string `json:"origin"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
||||
}
|
||||
out := make([]Summary, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, Summary{
|
||||
Summary: checklists.Summary{
|
||||
Slug: e.Template.Slug,
|
||||
TitleDE: e.Template.TitleDE,
|
||||
TitleEN: e.Template.TitleEN,
|
||||
DescriptionDE: e.Template.DescriptionDE,
|
||||
DescriptionEN: e.Template.DescriptionEN,
|
||||
Regime: e.Template.Regime,
|
||||
CourtDE: e.Template.CourtDE,
|
||||
CourtEN: e.Template.CourtEN,
|
||||
ItemCount: checklists.TotalItems(e.Template),
|
||||
},
|
||||
Origin: e.Origin,
|
||||
Visibility: e.Visibility,
|
||||
OwnerEmail: e.OwnerEmail,
|
||||
OwnerDisplayName: e.OwnerDisplayName,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleChecklistAPI returns one template by slug. Looks up static
|
||||
// catalog first (always visible), then authored DB rows via the
|
||||
// catalog with visibility check.
|
||||
func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
c, ok := checklists.Find(slug)
|
||||
if !ok {
|
||||
// Static-first path keeps the no-DB deploy functional and is the
|
||||
// common case for the curated templates.
|
||||
if c, ok := checklists.Find(slug); ok {
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
return
|
||||
}
|
||||
if dbSvc == nil || dbSvc.checklistCatalog == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
|
||||
return
|
||||
}
|
||||
// Re-render as the bilingual Template shape plus a thin meta block.
|
||||
// Version is included so the instance detail page can decide whether
|
||||
// to show the "template updated since this instance was created"
|
||||
// badge (Slice C).
|
||||
type templateWithMeta struct {
|
||||
checklists.Template
|
||||
Origin string `json:"origin"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
writeJSON(w, http.StatusOK, templateWithMeta{
|
||||
Template: entry.Template,
|
||||
Origin: entry.Origin,
|
||||
Visibility: entry.Visibility,
|
||||
OwnerEmail: entry.OwnerEmail,
|
||||
OwnerDisplayName: entry.OwnerDisplayName,
|
||||
Version: entry.Version,
|
||||
})
|
||||
}
|
||||
|
||||
func handleChecklistsFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -23,6 +23,13 @@ func handleDeadlinesDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/deadlines-detail.html")
|
||||
}
|
||||
|
||||
// handleDeadlinesCalendarPage 301-redirects the legacy standalone
|
||||
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
|
||||
// m/paliad#55). The standalone page was orphaned in navigation since
|
||||
// t-paliad-110 — Sidebar/BottomNav already point at /events?type=…, and
|
||||
// the canonical calendar lives inside that page's view chip. The
|
||||
// redirect preserves bookmarks and external links without a duplicate
|
||||
// rendering pipeline.
|
||||
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/deadlines-calendar.html")
|
||||
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ type Services struct {
|
||||
EventType *services.EventTypeService
|
||||
Dashboard *services.DashboardService
|
||||
Note *services.NoteService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
ChecklistCatalog *services.ChecklistCatalogService
|
||||
ChecklistTemplate *services.ChecklistTemplateService
|
||||
ChecklistShare *services.ChecklistShareService
|
||||
ChecklistPromotion *services.ChecklistPromotionService
|
||||
Mail *services.MailService
|
||||
Invite *services.InviteService
|
||||
Agenda *services.AgendaService
|
||||
@@ -144,7 +148,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
eventType: svc.EventType,
|
||||
dashboard: svc.Dashboard,
|
||||
note: svc.Note,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
checklistCatalog: svc.ChecklistCatalog,
|
||||
checklistTemplate: svc.ChecklistTemplate,
|
||||
checklistShare: svc.ChecklistShare,
|
||||
checklistPromotion: svc.ChecklistPromotion,
|
||||
mail: svc.Mail,
|
||||
invite: svc.Invite,
|
||||
agenda: svc.Agenda,
|
||||
@@ -248,11 +256,25 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/tools/gebuehrentabellen/lookup", handleGebuehrentabellenLookup)
|
||||
protected.HandleFunc("POST /api/tools/gebuehrentabellen/feedback", handleGebuehrentabellenFeedback)
|
||||
protected.HandleFunc("GET /checklists", handleChecklistsPage)
|
||||
protected.HandleFunc("GET /checklists/new", handleChecklistsAuthorPage)
|
||||
protected.HandleFunc("GET /checklists/instances/{id}", handleChecklistInstancePage)
|
||||
protected.HandleFunc("GET /checklists/templates/{slug}/edit", handleChecklistsAuthorPage)
|
||||
protected.HandleFunc("GET /checklists/{slug}", handleChecklistDetailPage)
|
||||
protected.HandleFunc("GET /api/checklists", handleChecklistsAPI)
|
||||
protected.HandleFunc("GET /api/checklists/{slug}", handleChecklistAPI)
|
||||
protected.HandleFunc("POST /api/checklists/feedback", handleChecklistsFeedback)
|
||||
// t-paliad-225 Slice A — user-authored templates (paliad.checklists).
|
||||
protected.HandleFunc("GET /api/checklists/templates/mine", handleListMyChecklistTemplates)
|
||||
protected.HandleFunc("POST /api/checklists/templates", handleCreateChecklistTemplate)
|
||||
protected.HandleFunc("PATCH /api/checklists/templates/{slug}", handleUpdateChecklistTemplate)
|
||||
protected.HandleFunc("PATCH /api/checklists/templates/{slug}/visibility", handleSetChecklistTemplateVisibility)
|
||||
protected.HandleFunc("DELETE /api/checklists/templates/{slug}", handleDeleteChecklistTemplate)
|
||||
// t-paliad-225 Slice B — explicit sharing + admin promotion.
|
||||
protected.HandleFunc("GET /api/checklists/templates/{slug}/shares", handleListChecklistShares)
|
||||
protected.HandleFunc("POST /api/checklists/templates/{slug}/shares", handleGrantChecklistShare)
|
||||
protected.HandleFunc("DELETE /api/checklists/shares/{id}", handleRevokeChecklistShare)
|
||||
protected.HandleFunc("POST /api/admin/checklists/{slug}/promote", handlePromoteChecklist)
|
||||
protected.HandleFunc("POST /api/admin/checklists/{slug}/demote", handleDemoteChecklist)
|
||||
protected.HandleFunc("GET /api/checklists/{slug}/instances", handleListChecklistInstancesForTemplate)
|
||||
protected.HandleFunc("POST /api/checklists/{slug}/instances", handleCreateChecklistInstance)
|
||||
protected.HandleFunc("GET /api/checklist-instances", handleListAllChecklistInstances)
|
||||
@@ -509,6 +531,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
|
||||
protected.HandleFunc("PATCH /api/admin/users/{id}", adminGate(users, handleAdminUpdateUser))
|
||||
protected.HandleFunc("DELETE /api/admin/users/{id}", adminGate(users, handleAdminDeleteUser))
|
||||
|
||||
@@ -38,7 +38,11 @@ type dbServices struct {
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistShare *services.ChecklistShareService
|
||||
checklistPromotion *services.ChecklistPromotionService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
|
||||
@@ -32,3 +32,28 @@ func TestRegisterLegacyRedirects_SubProjectsAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-224: /deadlines/calendar and /appointments/calendar 301 to
|
||||
// the canonical /events Kalender tab. Pins the redirect target so a
|
||||
// future refactor doesn't silently break the bookmark contract.
|
||||
func TestStandaloneCalendarHandlers_RedirectToEventsKalender(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
want string
|
||||
}{
|
||||
{"deadlines", handleDeadlinesCalendarPage, "/events?type=deadline&view=calendar"},
|
||||
{"appointments", handleAppointmentsCalendarPage, "/events?type=appointment&view=calendar"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil) // path ignored — handler is direct
|
||||
w := httptest.NewRecorder()
|
||||
tc.handler(w, req)
|
||||
if w.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("%s: status = %d, want %d", tc.name, w.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != tc.want {
|
||||
t.Fatalf("%s: Location = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,22 +421,32 @@ type Note struct {
|
||||
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
|
||||
}
|
||||
|
||||
// ChecklistInstance is one user's instantiation of a static checklist
|
||||
// template (defined in internal/checklists). Checkbox state lives in the
|
||||
// `state` jsonb column.
|
||||
// ChecklistInstance is one user's instantiation of a checklist template
|
||||
// (static catalog in internal/checklists OR authored row in
|
||||
// paliad.checklists). Checkbox state lives in the `state` jsonb column.
|
||||
//
|
||||
// Visibility mirrors Appointment: project_id nullable. Personal instances
|
||||
// (project_id NULL) are creator-only; Project-linked instances follow
|
||||
// paliad.can_see_project.
|
||||
//
|
||||
// TemplateSnapshot captures the template body at instance create time so
|
||||
// subsequent template edits / visibility narrowing don't affect existing
|
||||
// instances (t-paliad-225 Slice A). NULL on pre-mig-114 rows; the
|
||||
// service layer falls back to live catalog lookup in that case.
|
||||
type ChecklistInstance struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
TemplateSlug string `db:"template_slug" json:"template_slug"`
|
||||
Name string `db:"name" json:"name"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
State json.RawMessage `db:"state" json:"state"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
TemplateSlug string `db:"template_slug" json:"template_slug"`
|
||||
Name string `db:"name" json:"name"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
State json.RawMessage `db:"state" json:"state"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
TemplateSnapshot NullableJSON `db:"template_snapshot" json:"template_snapshot,omitempty"`
|
||||
// TemplateVersion is the checklists.version at instance create time.
|
||||
// NULL on pre-Slice-C rows where versioning wasn't captured; the
|
||||
// "outdated" badge stays off in that case.
|
||||
TemplateVersion *int `db:"template_version" json:"template_version,omitempty"`
|
||||
}
|
||||
|
||||
// ChecklistInstanceWithProject enriches an instance with its parent Project
|
||||
@@ -447,6 +457,37 @@ type ChecklistInstanceWithProject struct {
|
||||
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
||||
}
|
||||
|
||||
// Checklist is one authored template row in paliad.checklists. Augments
|
||||
// the static Go catalog (internal/checklists/templates.go) at read time
|
||||
// via ChecklistCatalogService. Body holds the groups + items as JSONB.
|
||||
type Checklist struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Regime string `db:"regime" json:"regime"`
|
||||
Court string `db:"court" json:"court"`
|
||||
Reference string `db:"reference" json:"reference"`
|
||||
Deadline string `db:"deadline" json:"deadline"`
|
||||
Lang string `db:"lang" json:"lang"`
|
||||
Body json.RawMessage `db:"body" json:"body"`
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
PromotedAt *time.Time `db:"promoted_at" json:"promoted_at,omitempty"`
|
||||
PromotedBy *uuid.UUID `db:"promoted_by" json:"promoted_by,omitempty"`
|
||||
Version int `db:"version" json:"version"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ChecklistWithOwner enriches a Checklist row with author display fields
|
||||
// for list views (Meine Vorlagen, Gallery).
|
||||
type ChecklistWithOwner struct {
|
||||
Checklist
|
||||
OwnerEmail string `db:"owner_email" json:"owner_email"`
|
||||
OwnerDisplayName string `db:"owner_display_name" json:"owner_display_name"`
|
||||
}
|
||||
|
||||
// UserCalDAVConfig holds one user's external CalDAV connection. The password
|
||||
// is never returned in API responses; only the public fields are exposed.
|
||||
type UserCalDAVConfig struct {
|
||||
|
||||
309
internal/services/checklist_catalog_service.go
Normal file
309
internal/services/checklist_catalog_service.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/checklists"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// ChecklistCatalogService unifies the static Go template catalog
|
||||
// (internal/checklists/templates.go) and the user-authored DB catalog
|
||||
// (paliad.checklists, mig 114) into a single read facade.
|
||||
//
|
||||
// Slug uniqueness is enforced across both spaces at write time by
|
||||
// ChecklistTemplateService (authored slugs get a 'u-' prefix and we
|
||||
// reject collisions with static slugs). Catalog lookups prefer static
|
||||
// templates on collision so a stray DB row never shadows curated
|
||||
// content — see Find().
|
||||
type ChecklistCatalogService struct {
|
||||
db *sqlx.DB
|
||||
staticSlugs map[string]bool
|
||||
}
|
||||
|
||||
// NewChecklistCatalogService wires the service and pre-computes the
|
||||
// static-slug set used for collision detection at write + read time.
|
||||
func NewChecklistCatalogService(db *sqlx.DB) *ChecklistCatalogService {
|
||||
set := make(map[string]bool, len(checklists.Templates))
|
||||
for _, t := range checklists.Templates {
|
||||
set[t.Slug] = true
|
||||
}
|
||||
return &ChecklistCatalogService{db: db, staticSlugs: set}
|
||||
}
|
||||
|
||||
// CatalogEntry is one unified entry — either a static template or an
|
||||
// authored DB row. Origin identifies the source so the UI can render
|
||||
// provenance ("Erstellt von <author>" for authored, plain title for
|
||||
// static).
|
||||
type CatalogEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Origin string `json:"origin"` // "static" | "authored"
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerID *uuid.UUID `json:"owner_id,omitempty"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
||||
// Version of the underlying row. 1 for static templates (they
|
||||
// re-version implicitly with the deploy that ships them); the live
|
||||
// counter from paliad.checklists.version for authored rows.
|
||||
Version int `json:"version"`
|
||||
Template checklists.Template `json:"template"`
|
||||
}
|
||||
|
||||
// IsStaticSlug reports whether the given slug names a curated static
|
||||
// template. Called by ChecklistTemplateService.Create to reject author
|
||||
// slugs that would shadow a curated entry.
|
||||
func (s *ChecklistCatalogService) IsStaticSlug(slug string) bool {
|
||||
return s.staticSlugs[slug]
|
||||
}
|
||||
|
||||
// ListVisible returns every catalog entry the caller can see — every
|
||||
// static template (always visible) plus every authored DB row that
|
||||
// passes paliad.can_see_checklist via RLS.
|
||||
//
|
||||
// Ordering: static templates first in their definition order, then
|
||||
// authored rows alphabetised by title.
|
||||
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error) {
|
||||
out := make([]CatalogEntry, 0, len(checklists.Templates))
|
||||
for _, t := range checklists.Templates {
|
||||
out = append(out, CatalogEntry{
|
||||
Slug: t.Slug,
|
||||
Origin: "static",
|
||||
Visibility: "static",
|
||||
Version: 1,
|
||||
Template: t,
|
||||
})
|
||||
}
|
||||
|
||||
if s.db == nil {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
rows, err := s.fetchVisibleAuthored(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
return strings.ToLower(rows[i].Title) < strings.ToLower(rows[j].Title)
|
||||
})
|
||||
|
||||
for _, r := range rows {
|
||||
// Skip the row if it collides with a static slug — static wins.
|
||||
if s.staticSlugs[r.Slug] {
|
||||
continue
|
||||
}
|
||||
tpl, err := s.rowToTemplate(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerID := r.OwnerID
|
||||
out = append(out, CatalogEntry{
|
||||
Slug: r.Slug,
|
||||
Origin: "authored",
|
||||
Visibility: r.Visibility,
|
||||
OwnerID: &ownerID,
|
||||
OwnerEmail: r.OwnerEmail,
|
||||
OwnerDisplayName: r.OwnerDisplayName,
|
||||
Version: r.Version,
|
||||
Template: tpl,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Find resolves a slug to a single catalog entry, applying visibility
|
||||
// (RLS for authored rows; static always visible). Returns ErrNotVisible
|
||||
// if the slug is unknown or the caller can't see the authored row.
|
||||
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error) {
|
||||
if t, ok := checklists.Find(slug); ok {
|
||||
return &CatalogEntry{
|
||||
Slug: t.Slug,
|
||||
Origin: "static",
|
||||
Visibility: "static",
|
||||
Version: 1,
|
||||
Template: t,
|
||||
}, nil
|
||||
}
|
||||
if s.db == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
row, err := s.fetchAuthoredBySlug(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
tpl, err := s.rowToTemplate(*row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerID := row.OwnerID
|
||||
return &CatalogEntry{
|
||||
Slug: row.Slug,
|
||||
Origin: "authored",
|
||||
Visibility: row.Visibility,
|
||||
OwnerID: &ownerID,
|
||||
OwnerEmail: row.OwnerEmail,
|
||||
OwnerDisplayName: row.OwnerDisplayName,
|
||||
Version: row.Version,
|
||||
Template: tpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SnapshotBody returns the template body as JSONB suitable for storing
|
||||
// in paliad.checklist_instances.template_snapshot. For static templates
|
||||
// we marshal the full Template struct; for authored rows we return the
|
||||
// body column directly (it already has the right shape — groups[]).
|
||||
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error) {
|
||||
entry, err := s.Find(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(entry.Template)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snapshot marshal: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// --- internals ------------------------------------------------------------
|
||||
|
||||
const authoredWithOwnerSelect = `SELECT c.id, c.slug, c.owner_id, c.title, c.description,
|
||||
c.regime, c.court, c.reference, c.deadline, c.lang, c.body, c.visibility,
|
||||
c.promoted_at, c.promoted_by, c.version, c.created_at, c.updated_at,
|
||||
u.email AS owner_email,
|
||||
u.display_name AS owner_display_name
|
||||
FROM paliad.checklists c
|
||||
JOIN paliad.users u ON u.id = c.owner_id`
|
||||
|
||||
// checklistVisibilityPredicate mirrors paliad.can_see_checklist for the
|
||||
// service-role connection (which bypasses RLS). Covers all 6 branches
|
||||
// from mig 115: owner + firm/global + global_admin + 4 share-recipient
|
||||
// kinds (user / office / partner_unit / project).
|
||||
//
|
||||
// One positional arg ($userArg) for the caller UUID. Reused several
|
||||
// times across the branches; that's fine — Postgres positional
|
||||
// placeholders evaluate the arg once per reference, no extra param
|
||||
// binding overhead.
|
||||
func checklistVisibilityPredicate(alias string, userArg int) string {
|
||||
return fmt.Sprintf(`(%s.owner_id = $%d
|
||||
OR %s.visibility IN ('firm', 'global')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $%d AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = $%d
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = $%d
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = $%d
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
OR EXISTS (
|
||||
-- Share-to-project resolution: inline ltree walk over
|
||||
-- paliad.projects.path because paliad.can_see_project
|
||||
-- reads auth.uid() which is NULL on the service-role
|
||||
-- connection (same pattern as visibility.go).
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.projects p
|
||||
ON p.id = s.recipient_project_id
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = $%d
|
||||
AND pt.project_id = ANY(CAST(string_to_array(p.path, '.') AS uuid[]))
|
||||
WHERE s.checklist_id = %s.id
|
||||
AND s.recipient_kind = 'project'
|
||||
))`,
|
||||
alias, userArg, // owner
|
||||
alias, // firm/global visibility col
|
||||
userArg, // global_admin
|
||||
alias, userArg, // share: user
|
||||
userArg, alias, // share: office
|
||||
userArg, alias, // share: partner_unit
|
||||
userArg, alias, // share: project (ltree walk)
|
||||
)
|
||||
}
|
||||
|
||||
func (s *ChecklistCatalogService) fetchVisibleAuthored(ctx context.Context, userID uuid.UUID) ([]models.ChecklistWithOwner, error) {
|
||||
rows := []models.ChecklistWithOwner{}
|
||||
q := authoredWithOwnerSelect + `
|
||||
WHERE ` + checklistVisibilityPredicate("c", 1) + `
|
||||
ORDER BY c.title ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, userID); err != nil {
|
||||
return nil, fmt.Errorf("list authored checklists: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *ChecklistCatalogService) fetchAuthoredBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.ChecklistWithOwner, error) {
|
||||
var row models.ChecklistWithOwner
|
||||
q := authoredWithOwnerSelect + `
|
||||
WHERE c.slug = $2
|
||||
AND ` + checklistVisibilityPredicate("c", 1)
|
||||
err := s.db.GetContext(ctx, &row, q, userID, slug)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch authored checklist: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *ChecklistCatalogService) rowToTemplate(row models.ChecklistWithOwner) (checklists.Template, error) {
|
||||
// body jsonb holds { "groups": [...] }. Unmarshal into a thin local
|
||||
// shape because the full checklists.Template has DE/EN sibling
|
||||
// fields the author only fills one side of.
|
||||
var bodyShape struct {
|
||||
Groups []checklists.Group `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(row.Body, &bodyShape); err != nil {
|
||||
return checklists.Template{}, fmt.Errorf("unmarshal authored body for %s: %w", row.Slug, err)
|
||||
}
|
||||
t := checklists.Template{
|
||||
Slug: row.Slug,
|
||||
Regime: row.Regime,
|
||||
Groups: bodyShape.Groups,
|
||||
ReferenceDE: row.Reference,
|
||||
ReferenceEN: row.Reference,
|
||||
DeadlineDE: row.Deadline,
|
||||
DeadlineEN: row.Deadline,
|
||||
CourtDE: row.Court,
|
||||
CourtEN: row.Court,
|
||||
}
|
||||
// Author picks one language per template — surface their title /
|
||||
// description on both sides so the existing bilingual frontend
|
||||
// renders without a special-case for authored entries.
|
||||
t.TitleDE = row.Title
|
||||
t.TitleEN = row.Title
|
||||
t.DescriptionDE = row.Description
|
||||
t.DescriptionEN = row.Description
|
||||
return t, nil
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/checklists"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
@@ -21,17 +20,23 @@ import (
|
||||
// Visibility mirrors paliad.appointments (project_id nullable):
|
||||
// - project_id NULL → creator-only (personal instance)
|
||||
// - project_id NOT NULL → parent Project's team-based gate
|
||||
//
|
||||
// Template resolution goes through ChecklistCatalogService so authored
|
||||
// templates (paliad.checklists, mig 114) and static templates work
|
||||
// interchangeably. Instance create captures a template_snapshot so
|
||||
// subsequent template edits/deletes don't disturb existing instances.
|
||||
type ChecklistInstanceService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
catalog *ChecklistCatalogService
|
||||
}
|
||||
|
||||
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService) *ChecklistInstanceService {
|
||||
return &ChecklistInstanceService{db: db, projects: projects}
|
||||
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService, catalog *ChecklistCatalogService) *ChecklistInstanceService {
|
||||
return &ChecklistInstanceService{db: db, projects: projects, catalog: catalog}
|
||||
}
|
||||
|
||||
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state,
|
||||
ci.created_by, ci.created_at, ci.updated_at`
|
||||
ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot, ci.template_version`
|
||||
|
||||
const checklistInstanceWithProjectSelect = `SELECT ` + checklistInstanceColumns + `,
|
||||
p.reference AS project_reference,
|
||||
@@ -55,8 +60,11 @@ type UpdateInstanceInput struct {
|
||||
|
||||
// ListForTemplate returns every visible instance of a given template.
|
||||
func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProject, error) {
|
||||
if _, ok := checklists.Find(slug); !ok {
|
||||
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
|
||||
if _, err := s.catalog.Find(ctx, userID, slug); err != nil {
|
||||
if errors.Is(err, ErrNotVisible) {
|
||||
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
user, err := s.projects.Users().GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -124,11 +132,25 @@ func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// Create inserts a new instance.
|
||||
// Create inserts a new instance. Captures a template_snapshot via the
|
||||
// catalog so subsequent template edits/deletes don't disturb this row
|
||||
// (t-paliad-225 Slice A).
|
||||
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
|
||||
if _, ok := checklists.Find(slug); !ok {
|
||||
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
|
||||
entry, err := s.catalog.Find(ctx, userID, slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotVisible) {
|
||||
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := s.catalog.SnapshotBody(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snapshot template body: %w", err)
|
||||
}
|
||||
// Slice C — capture the version we snapshotted from so the instance
|
||||
// detail page can show "template updated since this instance was
|
||||
// created" when the live version pulls ahead.
|
||||
snapshotVersion := entry.Version
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
@@ -153,9 +175,10 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.checklist_instances
|
||||
(id, template_slug, name, project_id, state, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, $6, $6)`,
|
||||
id, slug, name, input.ProjectID, userID, now,
|
||||
(id, template_slug, name, project_id, state, template_snapshot,
|
||||
template_version, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5::jsonb, $6, $7, $8, $8)`,
|
||||
id, slug, name, input.ProjectID, string(snapshot), snapshotVersion, userID, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert checklist_instance: %w", err)
|
||||
}
|
||||
@@ -366,7 +389,8 @@ func (s *ChecklistInstanceService) listWithProject(ctx context.Context, query st
|
||||
func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.ChecklistInstance, error) {
|
||||
var inst models.ChecklistInstance
|
||||
err := s.db.GetContext(ctx, &inst,
|
||||
`SELECT id, template_slug, name, project_id, state, created_by, created_at, updated_at
|
||||
`SELECT id, template_slug, name, project_id, state, created_by,
|
||||
created_at, updated_at, template_snapshot, template_version
|
||||
FROM paliad.checklist_instances WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
|
||||
153
internal/services/checklist_promotion_service.go
Normal file
153
internal/services/checklist_promotion_service.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ChecklistPromotionService implements the global_admin-only promote /
|
||||
// demote flow for paliad.checklists. Promote flips visibility to
|
||||
// 'global' and stamps promoted_at / promoted_by; demote flips it back
|
||||
// to a caller-chosen target ('firm' default — preserves visibility for
|
||||
// already-instantiated users).
|
||||
type ChecklistPromotionService struct {
|
||||
db *sqlx.DB
|
||||
templates *ChecklistTemplateService
|
||||
audit *SystemAuditLogService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
func NewChecklistPromotionService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistPromotionService {
|
||||
return &ChecklistPromotionService{db: db, templates: templates, audit: audit, users: users}
|
||||
}
|
||||
|
||||
// validDemoteTargets — narrowing the visibility back from 'global' is
|
||||
// only allowed to a state where the row is still meaningful. 'global'
|
||||
// would be a no-op; 'shared' would orphan existing instance owners who
|
||||
// already see it without a grant. Default is 'firm'.
|
||||
var validDemoteTargets = map[string]bool{"firm": true, "private": true}
|
||||
|
||||
// Promote flips an authored template to visibility='global'. Caller
|
||||
// must be global_admin. Emits 'checklist.promoted_global' audit event
|
||||
// with the prior visibility captured for the demote-undo path.
|
||||
func (s *ChecklistPromotionService) Promote(ctx context.Context, callerID uuid.UUID, slug string) error {
|
||||
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Visibility == "global" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin promote tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.checklists
|
||||
SET visibility = 'global',
|
||||
promoted_at = $2,
|
||||
promoted_by = $3,
|
||||
updated_at = $2
|
||||
WHERE id = $1`, row.ID, time.Now().UTC(), callerID); err != nil {
|
||||
return fmt.Errorf("promote checklist: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.promoted_global",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"owner_id": row.OwnerID,
|
||||
"prior_visibility": row.Visibility,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Demote narrows visibility from 'global' to target. target defaults to
|
||||
// 'firm' when empty. promoted_at / promoted_by are cleared.
|
||||
func (s *ChecklistPromotionService) Demote(ctx context.Context, callerID uuid.UUID, slug, target string) error {
|
||||
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := strings.ToLower(strings.TrimSpace(target))
|
||||
if t == "" {
|
||||
t = "firm"
|
||||
}
|
||||
if !validDemoteTargets[t] {
|
||||
return fmt.Errorf("%w: demote target must be firm | private, got %q", ErrInvalidInput, target)
|
||||
}
|
||||
if row.Visibility != "global" {
|
||||
return fmt.Errorf("%w: checklist is not currently promoted (visibility=%s)", ErrInvalidInput, row.Visibility)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin demote tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.checklists
|
||||
SET visibility = $2,
|
||||
promoted_at = NULL,
|
||||
promoted_by = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, row.ID, t); err != nil {
|
||||
return fmt.Errorf("demote checklist: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.demoted",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"target_visibility": t,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *ChecklistPromotionService) requireGlobalAdmin(ctx context.Context, callerID uuid.UUID) error {
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only global_admin can promote / demote checklists", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChecklistPromotionService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Email, nil
|
||||
}
|
||||
331
internal/services/checklist_share_service.go
Normal file
331
internal/services/checklist_share_service.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/offices"
|
||||
)
|
||||
|
||||
// ChecklistShareService is the write surface for paliad.checklist_shares
|
||||
// (mig 115). Owners grant; owner-or-global_admin revokes. ListGrants is
|
||||
// owner-only (returning all 4 recipient kinds) — recipients see "this
|
||||
// is shared with me" only implicitly via the visibility predicate.
|
||||
type ChecklistShareService struct {
|
||||
db *sqlx.DB
|
||||
templates *ChecklistTemplateService
|
||||
audit *SystemAuditLogService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
func NewChecklistShareService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistShareService {
|
||||
return &ChecklistShareService{db: db, templates: templates, audit: audit, users: users}
|
||||
}
|
||||
|
||||
// ShareGrantInput is the POST body for granting a share. Exactly one
|
||||
// of the recipient_* fields must be set, matching recipient_kind.
|
||||
type ShareGrantInput struct {
|
||||
RecipientKind string `json:"recipient_kind"`
|
||||
UserID *uuid.UUID `json:"recipient_user_id,omitempty"`
|
||||
Office string `json:"recipient_office,omitempty"`
|
||||
PartnerUnitID *uuid.UUID `json:"recipient_partner_unit_id,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"recipient_project_id,omitempty"`
|
||||
}
|
||||
|
||||
// Share is the row shape returned from list / grant calls.
|
||||
type Share struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ChecklistID uuid.UUID `db:"checklist_id" json:"checklist_id"`
|
||||
RecipientKind string `db:"recipient_kind" json:"recipient_kind"`
|
||||
RecipientUserID *uuid.UUID `db:"recipient_user_id" json:"recipient_user_id,omitempty"`
|
||||
RecipientOffice *string `db:"recipient_office" json:"recipient_office,omitempty"`
|
||||
RecipientPartnerUnitID *uuid.UUID `db:"recipient_partner_unit_id" json:"recipient_partner_unit_id,omitempty"`
|
||||
RecipientProjectID *uuid.UUID `db:"recipient_project_id" json:"recipient_project_id,omitempty"`
|
||||
GrantedBy uuid.UUID `db:"granted_by" json:"granted_by"`
|
||||
GrantedAt time.Time `db:"granted_at" json:"granted_at"`
|
||||
// Display-name enrichment for the recipient — owners want to see
|
||||
// "Sarah Schmidt" not just a UUID on the grants list.
|
||||
RecipientLabel string `db:"recipient_label" json:"recipient_label"`
|
||||
}
|
||||
|
||||
// Grant creates a new share row. Caller must own the parent checklist
|
||||
// (or be global_admin). Recipient validity (FK targets exist + kind
|
||||
// matches the populated recipient_* column) enforced before INSERT.
|
||||
func (s *ChecklistShareService) Grant(ctx context.Context, callerID uuid.UUID, slug string, input ShareGrantInput) (*Share, error) {
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ownership check — Grant is owner-only (global_admin can demote
|
||||
// global templates but doesn't author shares).
|
||||
if row.OwnerID != callerID {
|
||||
return nil, fmt.Errorf("%w: only the owner can grant shares", ErrForbidden)
|
||||
}
|
||||
|
||||
kind := strings.ToLower(strings.TrimSpace(input.RecipientKind))
|
||||
if err := validateShareInput(kind, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin grant tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.checklist_shares
|
||||
(id, checklist_id, recipient_kind, recipient_user_id, recipient_office,
|
||||
recipient_partner_unit_id, recipient_project_id, granted_by, granted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
id, row.ID, kind,
|
||||
input.UserID, nullableString(input.Office), input.PartnerUnitID, input.ProjectID,
|
||||
callerID, now,
|
||||
); err != nil {
|
||||
// Map the partial-unique-index conflict into a friendly 409.
|
||||
if pqUniqueViolation(err) {
|
||||
return nil, fmt.Errorf("%w: this recipient already has a grant on this checklist", ErrInvalidInput)
|
||||
}
|
||||
return nil, fmt.Errorf("insert checklist_share: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
meta := map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"share_id": id,
|
||||
"recipient_kind": kind,
|
||||
}
|
||||
switch kind {
|
||||
case "user":
|
||||
meta["recipient_user_id"] = input.UserID
|
||||
case "office":
|
||||
meta["recipient_office"] = input.Office
|
||||
case "partner_unit":
|
||||
meta["recipient_partner_unit_id"] = input.PartnerUnitID
|
||||
case "project":
|
||||
meta["recipient_project_id"] = input.ProjectID
|
||||
}
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.shared",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: meta,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit grant: %w", err)
|
||||
}
|
||||
return s.getShareByID(ctx, callerID, id)
|
||||
}
|
||||
|
||||
// Revoke deletes a share row. Owner of the parent checklist OR
|
||||
// global_admin. Audited as 'checklist.unshared' with the recipient meta
|
||||
// captured pre-delete.
|
||||
func (s *ChecklistShareService) Revoke(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) error {
|
||||
share, err := s.getShareByID(ctx, callerID, shareID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve owner of the parent checklist for the authorization gate.
|
||||
// templates.GetBySlug needs a slug we don't have; inline a minimal
|
||||
// owner lookup keyed on the share's checklist_id.
|
||||
var ownerID uuid.UUID
|
||||
if err := s.db.GetContext(ctx, &ownerID,
|
||||
`SELECT owner_id FROM paliad.checklists WHERE id = $1`, share.ChecklistID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotVisible
|
||||
}
|
||||
return fmt.Errorf("fetch checklist owner: %w", err)
|
||||
}
|
||||
if ownerID != callerID {
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only the owner or a global_admin can revoke a share", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin revoke tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.checklist_shares WHERE id = $1`, shareID); err != nil {
|
||||
return fmt.Errorf("delete checklist_share: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, callerID)
|
||||
meta := map[string]any{
|
||||
"checklist_id": share.ChecklistID,
|
||||
"share_id": share.ID,
|
||||
"recipient_kind": share.RecipientKind,
|
||||
}
|
||||
switch share.RecipientKind {
|
||||
case "user":
|
||||
meta["recipient_user_id"] = share.RecipientUserID
|
||||
case "office":
|
||||
meta["recipient_office"] = share.RecipientOffice
|
||||
case "partner_unit":
|
||||
meta["recipient_partner_unit_id"] = share.RecipientPartnerUnitID
|
||||
case "project":
|
||||
meta["recipient_project_id"] = share.RecipientProjectID
|
||||
}
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.unshared",
|
||||
ActorID: callerID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: meta,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListGrants returns every share row for the checklist. Owner-only —
|
||||
// recipients only learn about shares affecting them implicitly via the
|
||||
// visibility predicate.
|
||||
func (s *ChecklistShareService) ListGrants(ctx context.Context, callerID uuid.UUID, slug string) ([]Share, error) {
|
||||
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.OwnerID != callerID {
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return nil, fmt.Errorf("%w: only the owner or a global_admin can list shares", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
rows := []Share{}
|
||||
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
|
||||
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
|
||||
s.granted_by, s.granted_at,
|
||||
COALESCE(
|
||||
CASE s.recipient_kind
|
||||
WHEN 'user' THEN ru.display_name
|
||||
WHEN 'office' THEN s.recipient_office
|
||||
WHEN 'partner_unit' THEN pu.name
|
||||
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
|
||||
END,
|
||||
''
|
||||
) AS recipient_label
|
||||
FROM paliad.checklist_shares s
|
||||
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
|
||||
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
|
||||
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
|
||||
WHERE s.checklist_id = $1
|
||||
ORDER BY s.granted_at DESC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, row.ID); err != nil {
|
||||
return nil, fmt.Errorf("list checklist_shares: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// --- internals ------------------------------------------------------------
|
||||
|
||||
func (s *ChecklistShareService) getShareByID(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) (*Share, error) {
|
||||
var row Share
|
||||
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
|
||||
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
|
||||
s.granted_by, s.granted_at,
|
||||
COALESCE(
|
||||
CASE s.recipient_kind
|
||||
WHEN 'user' THEN ru.display_name
|
||||
WHEN 'office' THEN s.recipient_office
|
||||
WHEN 'partner_unit' THEN pu.name
|
||||
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
|
||||
END,
|
||||
''
|
||||
) AS recipient_label
|
||||
FROM paliad.checklist_shares s
|
||||
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
|
||||
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
|
||||
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
|
||||
WHERE s.id = $1`
|
||||
err := s.db.GetContext(ctx, &row, q, shareID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch checklist_share: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *ChecklistShareService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Email, nil
|
||||
}
|
||||
|
||||
// --- pure helpers ---------------------------------------------------------
|
||||
|
||||
func validateShareInput(kind string, input ShareGrantInput) error {
|
||||
switch kind {
|
||||
case "user":
|
||||
if input.UserID == nil {
|
||||
return fmt.Errorf("%w: recipient_user_id required when recipient_kind=user", ErrInvalidInput)
|
||||
}
|
||||
case "office":
|
||||
off := strings.TrimSpace(input.Office)
|
||||
if off == "" {
|
||||
return fmt.Errorf("%w: recipient_office required when recipient_kind=office", ErrInvalidInput)
|
||||
}
|
||||
if !offices.IsValid(off) {
|
||||
return fmt.Errorf("%w: unknown office %q", ErrInvalidInput, off)
|
||||
}
|
||||
case "partner_unit":
|
||||
if input.PartnerUnitID == nil {
|
||||
return fmt.Errorf("%w: recipient_partner_unit_id required when recipient_kind=partner_unit", ErrInvalidInput)
|
||||
}
|
||||
case "project":
|
||||
if input.ProjectID == nil {
|
||||
return fmt.Errorf("%w: recipient_project_id required when recipient_kind=project", ErrInvalidInput)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: recipient_kind must be user|office|partner_unit|project, got %q", ErrInvalidInput, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// pqUniqueViolation reports whether the error is a Postgres
|
||||
// unique_violation (SQLSTATE 23505). lib/pq exposes it via the .Code()
|
||||
// method; sqlx surfaces it untouched. We sniff via the error string to
|
||||
// avoid pulling in lib/pq's Error type here.
|
||||
func pqUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key")
|
||||
}
|
||||
107
internal/services/checklist_share_service_test.go
Normal file
107
internal/services/checklist_share_service_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestValidateShareInput(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
puID := uuid.New()
|
||||
prID := uuid.New()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
input ShareGrantInput
|
||||
wantErr bool
|
||||
}{
|
||||
{"user happy", "user", ShareGrantInput{RecipientKind: "user", UserID: &uid}, false},
|
||||
{"user missing id", "user", ShareGrantInput{RecipientKind: "user"}, true},
|
||||
{"office happy", "office", ShareGrantInput{RecipientKind: "office", Office: "munich"}, false},
|
||||
{"office unknown key", "office", ShareGrantInput{RecipientKind: "office", Office: "atlantis"}, true},
|
||||
{"office empty", "office", ShareGrantInput{RecipientKind: "office"}, true},
|
||||
{"partner_unit happy", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit", PartnerUnitID: &puID}, false},
|
||||
{"partner_unit missing id", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit"}, true},
|
||||
{"project happy", "project", ShareGrantInput{RecipientKind: "project", ProjectID: &prID}, false},
|
||||
{"project missing id", "project", ShareGrantInput{RecipientKind: "project"}, true},
|
||||
{"unknown kind", "bogus", ShareGrantInput{RecipientKind: "bogus"}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := validateShareInput(c.kind, c.input)
|
||||
if c.wantErr && !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("%s: expected ErrInvalidInput, got %v", c.name, err)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredicateIncludesAllShareBranches(t *testing.T) {
|
||||
pred := checklistVisibilityPredicate("c", 1)
|
||||
wants := []string{
|
||||
"c.owner_id = $1",
|
||||
"c.visibility IN ('firm', 'global')",
|
||||
"u.global_role = 'global_admin'",
|
||||
"s.recipient_kind = 'user'",
|
||||
"s.recipient_kind = 'office'",
|
||||
"s.recipient_kind = 'partner_unit'",
|
||||
"s.recipient_kind = 'project'",
|
||||
"paliad.checklist_shares",
|
||||
"paliad.partner_unit_members",
|
||||
"paliad.projects",
|
||||
"paliad.project_teams",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(pred, w) {
|
||||
t.Errorf("predicate missing %q in:\n%s", w, pred)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPqUniqueViolationDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
err string
|
||||
want bool
|
||||
}{
|
||||
{"pq: duplicate key value violates unique constraint \"checklist_shares_user_uniq\"", true},
|
||||
{"pq: 23505 something", true},
|
||||
{"some other error", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := pqUniqueViolation(errors.New(c.err))
|
||||
if got != c.want {
|
||||
t.Errorf("pqUniqueViolation(%q) = %v; want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
if pqUniqueViolation(nil) {
|
||||
t.Error("nil err should not be a unique violation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullableString(t *testing.T) {
|
||||
if got := nullableString(""); got != nil {
|
||||
t.Errorf("empty should map to nil, got %v", got)
|
||||
}
|
||||
if got := nullableString(" "); got != nil {
|
||||
t.Errorf("whitespace should map to nil, got %v", got)
|
||||
}
|
||||
if got := nullableString(" munich "); got != "munich" {
|
||||
t.Errorf("expected trimmed 'munich', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseSliceAVisibilityAcceptsShared(t *testing.T) {
|
||||
for _, v := range []string{"private", "firm", "shared"} {
|
||||
if _, err := normaliseSliceAVisibility(v); err != nil {
|
||||
t.Errorf("Slice-B visibility %q rejected: %v", v, err)
|
||||
}
|
||||
}
|
||||
if _, err := normaliseSliceAVisibility("global"); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("'global' should be rejected as author-set, got %v", err)
|
||||
}
|
||||
}
|
||||
586
internal/services/checklist_template_service.go
Normal file
586
internal/services/checklist_template_service.go
Normal file
@@ -0,0 +1,586 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/checklists"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// ChecklistTemplateService is the write surface for user-authored checklist
|
||||
// templates (paliad.checklists, mig 114). Create / Update / Delete on
|
||||
// owner-only paths; SetVisibility on private↔firm only (Slice A — Slice B
|
||||
// adds 'shared' grants, Slice C adds 'global' via admin promotion).
|
||||
type ChecklistTemplateService struct {
|
||||
db *sqlx.DB
|
||||
catalog *ChecklistCatalogService
|
||||
audit *SystemAuditLogService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
func NewChecklistTemplateService(db *sqlx.DB, catalog *ChecklistCatalogService, audit *SystemAuditLogService, users *UserService) *ChecklistTemplateService {
|
||||
return &ChecklistTemplateService{db: db, catalog: catalog, audit: audit, users: users}
|
||||
}
|
||||
|
||||
// CreateTemplateInput is the POST body for authoring a new template.
|
||||
//
|
||||
// Body carries the groups[] / items[] sub-tree as JSONB; the surrounding
|
||||
// metadata (title, regime, etc.) lives on dedicated columns. The
|
||||
// handler validates the body shape upstream.
|
||||
type CreateTemplateInput struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Regime string `json:"regime"`
|
||||
Court string `json:"court"`
|
||||
Reference string `json:"reference"`
|
||||
Deadline string `json:"deadline"`
|
||||
Lang string `json:"lang"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
// UpdateTemplateInput patches the owner-editable fields. Any field left
|
||||
// nil is unchanged.
|
||||
type UpdateTemplateInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Regime *string `json:"regime,omitempty"`
|
||||
Court *string `json:"court,omitempty"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
Body *json.RawMessage `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
validRegimes = map[string]bool{"UPC": true, "DE": true, "EPA": true, "OTHER": true}
|
||||
validLangs = map[string]bool{"de": true, "en": true}
|
||||
// Author-settable visibilities. 'shared' is implicit (set
|
||||
// automatically when the first checklist_shares row exists); 'global'
|
||||
// is admin-only via ChecklistPromotionService.
|
||||
validVisibilities = map[string]bool{"private": true, "firm": true, "shared": true}
|
||||
titleMaxLen = 200
|
||||
descriptionMaxLen = 2000
|
||||
freeTextMaxLen = 200
|
||||
slugSafeChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
)
|
||||
|
||||
// Create inserts a new authored template owned by userID. Returns the
|
||||
// created row; emits a `checklist.authored` audit event.
|
||||
func (s *ChecklistTemplateService) Create(ctx context.Context, userID uuid.UUID, input CreateTemplateInput) (*models.Checklist, error) {
|
||||
title, err := requireNonEmptyTrimmed(input.Title, "title", titleMaxLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
regime, err := normaliseRegime(input.Regime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lang, err := normaliseLang(input.Lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visibility, err := normaliseSliceAVisibility(input.Visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateBodyShape(input.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slug, err := s.generateSlug(ctx, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
id := uuid.New()
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin create tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.checklists
|
||||
(id, slug, owner_id, title, description, regime, court, reference,
|
||||
deadline, lang, body, visibility, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $13)`,
|
||||
id, slug, userID, title,
|
||||
clampFreeText(input.Description, descriptionMaxLen),
|
||||
regime,
|
||||
clampFreeText(input.Court, freeTextMaxLen),
|
||||
clampFreeText(input.Reference, freeTextMaxLen),
|
||||
clampFreeText(input.Deadline, freeTextMaxLen),
|
||||
lang,
|
||||
string(input.Body),
|
||||
visibility,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert checklist: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, userID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.authored",
|
||||
ActorID: userID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": id,
|
||||
"slug": slug,
|
||||
"visibility": visibility,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create checklist: %w", err)
|
||||
}
|
||||
return s.GetBySlug(ctx, userID, slug)
|
||||
}
|
||||
|
||||
// Update mutates an authored template. Owner-only; non-owner attempts
|
||||
// return ErrForbidden. Emits `checklist.edited` with the names of the
|
||||
// changed fields in metadata.changed_fields[].
|
||||
func (s *ChecklistTemplateService) Update(ctx context.Context, userID uuid.UUID, slug string, input UpdateTemplateInput) (*models.Checklist, error) {
|
||||
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
next := 1
|
||||
changed := []string{}
|
||||
appendSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
|
||||
if input.Title != nil {
|
||||
t, err := requireNonEmptyTrimmed(*input.Title, "title", titleMaxLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("title", t)
|
||||
changed = append(changed, "title")
|
||||
}
|
||||
if input.Description != nil {
|
||||
appendSet("description", clampFreeText(*input.Description, descriptionMaxLen))
|
||||
changed = append(changed, "description")
|
||||
}
|
||||
if input.Regime != nil {
|
||||
r, err := normaliseRegime(*input.Regime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("regime", r)
|
||||
changed = append(changed, "regime")
|
||||
}
|
||||
if input.Court != nil {
|
||||
appendSet("court", clampFreeText(*input.Court, freeTextMaxLen))
|
||||
changed = append(changed, "court")
|
||||
}
|
||||
if input.Reference != nil {
|
||||
appendSet("reference", clampFreeText(*input.Reference, freeTextMaxLen))
|
||||
changed = append(changed, "reference")
|
||||
}
|
||||
if input.Deadline != nil {
|
||||
appendSet("deadline", clampFreeText(*input.Deadline, freeTextMaxLen))
|
||||
changed = append(changed, "deadline")
|
||||
}
|
||||
if input.Body != nil {
|
||||
if err := validateBodyShape(*input.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("body = $%d::jsonb", next))
|
||||
args = append(args, string(*input.Body))
|
||||
next++
|
||||
changed = append(changed, "body")
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// Version bump (Slice C). Title and body are the meaningful edits
|
||||
// that warrant a "your snapshot is outdated" badge on existing
|
||||
// instances. Pure metadata tweaks (description / court / reference
|
||||
// / deadline) update updated_at but don't bump version — we don't
|
||||
// want every typo correction to nag users with an outdated badge.
|
||||
versionBumped := false
|
||||
for _, f := range changed {
|
||||
if f == "title" || f == "body" {
|
||||
versionBumped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if versionBumped {
|
||||
sets = append(sets, "version = version + 1")
|
||||
}
|
||||
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
args = append(args, row.ID)
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin update tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
q := fmt.Sprintf(`UPDATE paliad.checklists SET %s WHERE id = $%d`,
|
||||
strings.Join(sets, ", "), next)
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("update checklist: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, userID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.edited",
|
||||
ActorID: userID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"changed_fields": changed,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Slice C — emit a separate 'checklist.versioned' event when the
|
||||
// edit actually bumped the version. Dashboards / future popularity
|
||||
// sort can read this without parsing changed_fields[].
|
||||
if versionBumped {
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.versioned",
|
||||
ActorID: userID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"prior_version": row.Version,
|
||||
"new_version": row.Version + 1,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update checklist: %w", err)
|
||||
}
|
||||
return s.GetBySlug(ctx, userID, slug)
|
||||
}
|
||||
|
||||
// SetVisibility flips the visibility level. Slice A allows only the
|
||||
// private ↔ firm transitions; Slice B opens 'shared' (requires share
|
||||
// grants); Slice C opens 'global' via the admin promotion service.
|
||||
func (s *ChecklistTemplateService) SetVisibility(ctx context.Context, userID uuid.UUID, slug string, visibility string) (*models.Checklist, error) {
|
||||
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := normaliseSliceAVisibility(visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Visibility == target {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin visibility tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.checklists
|
||||
SET visibility = $2, updated_at = now()
|
||||
WHERE id = $1`, row.ID, target); err != nil {
|
||||
return nil, fmt.Errorf("update visibility: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, userID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.visibility_changed",
|
||||
ActorID: userID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"from": row.Visibility,
|
||||
"to": target,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit visibility: %w", err)
|
||||
}
|
||||
return s.GetBySlug(ctx, userID, slug)
|
||||
}
|
||||
|
||||
// Delete removes the authored template. Existing instances survive via
|
||||
// template_snapshot; new instance creation against this slug fails.
|
||||
func (s *ChecklistTemplateService) Delete(ctx context.Context, userID uuid.UUID, slug string) error {
|
||||
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin delete tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.checklists WHERE id = $1`, row.ID); err != nil {
|
||||
return fmt.Errorf("delete checklist: %w", err)
|
||||
}
|
||||
|
||||
actorEmail, _ := s.actorEmail(ctx, userID)
|
||||
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
||||
EventType: "checklist.deleted",
|
||||
ActorID: userID,
|
||||
ActorEmail: actorEmail,
|
||||
Metadata: map[string]any{
|
||||
"checklist_id": row.ID,
|
||||
"slug": slug,
|
||||
"was_visibility": row.Visibility,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListOwnedBy returns every authored template owned by the caller. Used
|
||||
// by the 'Meine Vorlagen' tab on /checklists.
|
||||
func (s *ChecklistTemplateService) ListOwnedBy(ctx context.Context, userID uuid.UUID) ([]models.Checklist, error) {
|
||||
rows := []models.Checklist{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, slug, owner_id, title, description, regime, court, reference,
|
||||
deadline, lang, body, visibility, promoted_at, promoted_by,
|
||||
version, created_at, updated_at
|
||||
FROM paliad.checklists
|
||||
WHERE owner_id = $1
|
||||
ORDER BY updated_at DESC`, userID); err != nil {
|
||||
return nil, fmt.Errorf("list owned checklists: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetBySlug returns one authored template by slug; applies visibility.
|
||||
func (s *ChecklistTemplateService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
|
||||
var row models.Checklist
|
||||
q := `SELECT id, slug, owner_id, title, description, regime, court, reference,
|
||||
deadline, lang, body, visibility, promoted_at, promoted_by,
|
||||
version, created_at, updated_at
|
||||
FROM paliad.checklists
|
||||
WHERE slug = $2
|
||||
AND ` + checklistVisibilityPredicate("paliad.checklists", 1)
|
||||
err := s.db.GetContext(ctx, &row, q, userID, slug)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch checklist: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// --- internals ------------------------------------------------------------
|
||||
|
||||
// requireOwnerOrAdmin fetches the row and returns it iff caller is owner
|
||||
// or global_admin. Other callers get ErrForbidden (template visible to
|
||||
// many users, only some can mutate).
|
||||
func (s *ChecklistTemplateService) requireOwnerOrAdmin(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
|
||||
row, err := s.GetBySlug(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.OwnerID == userID {
|
||||
return row, nil
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != nil && user.GlobalRole == "global_admin" {
|
||||
return row, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: only the owner or a global_admin can modify this checklist", ErrForbidden)
|
||||
}
|
||||
|
||||
func (s *ChecklistTemplateService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Email, nil
|
||||
}
|
||||
|
||||
// generateSlug builds a 'u-<title-slug>-<6hex>' slug. Three retries on
|
||||
// collision (against authored table + static catalog). After three
|
||||
// failures we fall back to a pure-random suffix so the create path
|
||||
// never wedges.
|
||||
func (s *ChecklistTemplateService) generateSlug(ctx context.Context, title string) (string, error) {
|
||||
base := slugifyTitle(title)
|
||||
if base == "" {
|
||||
base = "checklist"
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
suffix, err := randomHex(3)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
slug := "u-" + base + "-" + suffix
|
||||
if len(slug) > 64 {
|
||||
slug = slug[:64]
|
||||
}
|
||||
taken, err := s.slugTaken(ctx, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !taken {
|
||||
return slug, nil
|
||||
}
|
||||
}
|
||||
suffix, err := randomHex(6)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "u-" + suffix, nil
|
||||
}
|
||||
|
||||
func (s *ChecklistTemplateService) slugTaken(ctx context.Context, slug string) (bool, error) {
|
||||
if s.catalog.IsStaticSlug(slug) {
|
||||
return true, nil
|
||||
}
|
||||
var n int
|
||||
if err := s.db.GetContext(ctx, &n,
|
||||
`SELECT count(*) FROM paliad.checklists WHERE slug = $1`, slug); err != nil {
|
||||
return false, fmt.Errorf("slug taken check: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// --- pure helpers ---------------------------------------------------------
|
||||
|
||||
func slugifyTitle(title string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
s = strings.ReplaceAll(s, "ä", "ae")
|
||||
s = strings.ReplaceAll(s, "ö", "oe")
|
||||
s = strings.ReplaceAll(s, "ü", "ue")
|
||||
s = strings.ReplaceAll(s, "ß", "ss")
|
||||
s = slugSafeChars.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > 40 {
|
||||
s = s[:40]
|
||||
}
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func requireNonEmptyTrimmed(v, field string, max int) (string, error) {
|
||||
t := strings.TrimSpace(v)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("%w: %s is required", ErrInvalidInput, field)
|
||||
}
|
||||
if len(t) > max {
|
||||
return "", fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidInput, field, max)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func clampFreeText(v string, max int) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if len(v) > max {
|
||||
v = v[:max]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normaliseRegime(v string) (string, error) {
|
||||
r := strings.ToUpper(strings.TrimSpace(v))
|
||||
if r == "" {
|
||||
r = "OTHER"
|
||||
}
|
||||
if !validRegimes[r] {
|
||||
return "", fmt.Errorf("%w: regime must be UPC | DE | EPA | OTHER, got %q", ErrInvalidInput, v)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func normaliseLang(v string) (string, error) {
|
||||
l := strings.ToLower(strings.TrimSpace(v))
|
||||
if l == "" {
|
||||
l = "de"
|
||||
}
|
||||
if !validLangs[l] {
|
||||
return "", fmt.Errorf("%w: lang must be de | en, got %q", ErrInvalidInput, v)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func normaliseSliceAVisibility(v string) (string, error) {
|
||||
x := strings.ToLower(strings.TrimSpace(v))
|
||||
if x == "" {
|
||||
x = "private"
|
||||
}
|
||||
if !validVisibilities[x] {
|
||||
return "", fmt.Errorf("%w: visibility must be private | firm | shared, got %q (global is admin-only)", ErrInvalidInput, v)
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// validateBodyShape enforces { "groups": [...] } with at least one
|
||||
// non-empty group and at least one non-empty item somewhere. Authored
|
||||
// templates aren't useful without content.
|
||||
func validateBodyShape(body json.RawMessage) error {
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("%w: body is required", ErrInvalidInput)
|
||||
}
|
||||
var shape struct {
|
||||
Groups []checklists.Group `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &shape); err != nil {
|
||||
return fmt.Errorf("%w: body must be {\"groups\":[...]} (%v)", ErrInvalidInput, err)
|
||||
}
|
||||
if len(shape.Groups) == 0 {
|
||||
return fmt.Errorf("%w: body must contain at least one group", ErrInvalidInput)
|
||||
}
|
||||
totalItems := 0
|
||||
for _, g := range shape.Groups {
|
||||
totalItems += len(g.Items)
|
||||
}
|
||||
if totalItems == 0 {
|
||||
return fmt.Errorf("%w: body must contain at least one item", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
129
internal/services/checklist_template_service_test.go
Normal file
129
internal/services/checklist_template_service_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSlugifyTitle(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"UPC Klageschrift Strategie", "upc-klageschrift-strategie"},
|
||||
{"Hülle für Münch (München!)", "huelle-fuer-muench-muenchen"},
|
||||
{" ", ""},
|
||||
{"&&&", ""},
|
||||
{"A really really really really long title that ought to be clamped to forty chars max", "a-really-really-really-really-long-title"},
|
||||
{"Straße ABC", "strasse-abc"},
|
||||
{"---leading-and-trailing---", "leading-and-trailing"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := slugifyTitle(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("slugifyTitle(%q) = %q; want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseRegime(t *testing.T) {
|
||||
for _, valid := range []string{"upc", "DE", " epa ", "Other", ""} {
|
||||
if _, err := normaliseRegime(valid); err != nil {
|
||||
t.Errorf("normaliseRegime(%q) errored unexpectedly: %v", valid, err)
|
||||
}
|
||||
}
|
||||
if _, err := normaliseRegime("bogus"); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("normaliseRegime(bogus) expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseLang(t *testing.T) {
|
||||
for _, valid := range []string{"de", "EN", " ", ""} {
|
||||
if _, err := normaliseLang(valid); err != nil {
|
||||
t.Errorf("normaliseLang(%q) errored: %v", valid, err)
|
||||
}
|
||||
}
|
||||
if _, err := normaliseLang("fr"); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("normaliseLang(fr) expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseSliceAVisibility(t *testing.T) {
|
||||
// Slice B opened up 'shared' as a valid author-set visibility
|
||||
// (alongside 'private' and 'firm'). 'global' stays admin-only via
|
||||
// ChecklistPromotionService.
|
||||
for _, valid := range []string{"private", "firm", "shared", " ", ""} {
|
||||
if _, err := normaliseSliceAVisibility(valid); err != nil {
|
||||
t.Errorf("visibility(%q) errored: %v", valid, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"global", "public"} {
|
||||
if _, err := normaliseSliceAVisibility(bad); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("visibility(%q) expected ErrInvalidInput, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireNonEmptyTrimmed(t *testing.T) {
|
||||
if _, err := requireNonEmptyTrimmed(" ", "title", 200); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("empty title should be rejected, got %v", err)
|
||||
}
|
||||
if got, err := requireNonEmptyTrimmed(" hello ", "title", 200); err != nil || got != "hello" {
|
||||
t.Errorf("expected 'hello', got %q (err=%v)", got, err)
|
||||
}
|
||||
if _, err := requireNonEmptyTrimmed(strings.Repeat("x", 201), "title", 200); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("over-length title should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBodyShape(t *testing.T) {
|
||||
// Happy path: at least one group, at least one item.
|
||||
ok := json.RawMessage(`{"groups":[{"titleDE":"G1","titleEN":"G1","items":[{"labelDE":"X","labelEN":"X"}]}]}`)
|
||||
if err := validateBodyShape(ok); err != nil {
|
||||
t.Errorf("valid body rejected: %v", err)
|
||||
}
|
||||
// Empty groups.
|
||||
if err := validateBodyShape(json.RawMessage(`{"groups":[]}`)); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("empty groups expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
// Group with no items.
|
||||
if err := validateBodyShape(json.RawMessage(`{"groups":[{"titleDE":"G","titleEN":"G","items":[]}]}`)); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("empty items expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
// Missing field.
|
||||
if err := validateBodyShape(json.RawMessage(nil)); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("nil body expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
// Malformed JSON.
|
||||
if err := validateBodyShape(json.RawMessage(`{not json`)); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("malformed body expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecklistCatalogIsStaticSlug(t *testing.T) {
|
||||
// nil DB is fine — we never touch it in this test.
|
||||
cat := NewChecklistCatalogService(nil)
|
||||
if !cat.IsStaticSlug("upc-statement-of-claim") {
|
||||
t.Error("expected static slug to be detected")
|
||||
}
|
||||
if cat.IsStaticSlug("u-some-authored-slug") {
|
||||
t.Error("unexpected static-slug match for authored slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecklistVisibilityPredicate(t *testing.T) {
|
||||
got := checklistVisibilityPredicate("c", 1)
|
||||
for _, want := range []string{"c.owner_id = $1", "c.visibility IN ('firm', 'global')", "u.global_role = 'global_admin'"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("predicate missing %q in: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampFreeText(t *testing.T) {
|
||||
if got := clampFreeText(" hello ", 200); got != "hello" {
|
||||
t.Errorf("expected trimmed 'hello', got %q", got)
|
||||
}
|
||||
if got := clampFreeText(strings.Repeat("x", 250), 200); len(got) != 200 {
|
||||
t.Errorf("expected clamp to 200, got len=%d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ func EmailTemplateSampleData(key, lang, slot string) map[string]any {
|
||||
switch key {
|
||||
case EmailTemplateKeyInvitation:
|
||||
return invitationSample(lang)
|
||||
case EmailTemplateKeyAddUserWelcome:
|
||||
return addUserWelcomeSample(lang)
|
||||
case EmailTemplateKeyDeadlineDigest:
|
||||
return deadlineDigestSample(lang, slot)
|
||||
case EmailTemplateKeyBase:
|
||||
@@ -98,6 +100,30 @@ func deadlineDigestSample(lang, slot string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — sample data for the Add-User welcome mail.
|
||||
// The variable contract mirrors what UserService.AdminCreateUserFull
|
||||
// passes to MailService.SendTemplate at runtime.
|
||||
func addUserWelcomeSample(lang string) map[string]any {
|
||||
if lang == "en" {
|
||||
return map[string]any{
|
||||
"InviterName": "Maria Schmidt",
|
||||
"InviterEmail": "maria.schmidt@hlc.com",
|
||||
"ToEmail": "new.colleague@hlc.com",
|
||||
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
|
||||
"BaseURL": "https://paliad.de",
|
||||
"Firm": "HLC",
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"InviterName": "Maria Schmidt",
|
||||
"InviterEmail": "maria.schmidt@hlc.com",
|
||||
"ToEmail": "neu.kollege@hlc.de",
|
||||
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
|
||||
"BaseURL": "https://paliad.de",
|
||||
"Firm": "HLC",
|
||||
}
|
||||
}
|
||||
|
||||
func baseSample(lang string) map[string]any {
|
||||
subj := "Beispielbetreff"
|
||||
if lang == "en" {
|
||||
|
||||
@@ -41,11 +41,17 @@ const (
|
||||
EmailTemplateKeyInvitation = "invitation"
|
||||
EmailTemplateKeyDeadlineDigest = "deadline_digest"
|
||||
EmailTemplateKeyBase = "base"
|
||||
// EmailTemplateKeyAddUserWelcome — t-paliad-223 Slice B (#49). Sent when
|
||||
// a global_admin directly creates a paliad.users + auth.users pair from
|
||||
// /admin/team's "Konto direkt anlegen" form. Carries a Supabase
|
||||
// recovery-link so the new colleague can set their own password.
|
||||
EmailTemplateKeyAddUserWelcome = "add_user_welcome"
|
||||
)
|
||||
|
||||
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
|
||||
var CanonicalEmailTemplateKeys = []string{
|
||||
EmailTemplateKeyInvitation,
|
||||
EmailTemplateKeyAddUserWelcome,
|
||||
EmailTemplateKeyDeadlineDigest,
|
||||
EmailTemplateKeyBase,
|
||||
}
|
||||
@@ -420,6 +426,10 @@ var defaultSubjects = map[string]map[string]string{
|
||||
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
|
||||
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
|
||||
},
|
||||
EmailTemplateKeyAddUserWelcome: {
|
||||
"de": `[Paliad] Ihr Paliad-Konto ist bereit`,
|
||||
"en": `[Paliad] Your Paliad account is ready`,
|
||||
},
|
||||
EmailTemplateKeyDeadlineDigest: {
|
||||
"de": digestSubjectDE,
|
||||
"en": digestSubjectEN,
|
||||
|
||||
@@ -21,6 +21,8 @@ func EmailTemplateVariables(key string) []EmailTemplateVariable {
|
||||
switch key {
|
||||
case EmailTemplateKeyInvitation:
|
||||
return invitationVariables
|
||||
case EmailTemplateKeyAddUserWelcome:
|
||||
return addUserWelcomeVariables
|
||||
case EmailTemplateKeyDeadlineDigest:
|
||||
return deadlineDigestVariables
|
||||
case EmailTemplateKeyBase:
|
||||
@@ -51,6 +53,30 @@ var invitationVariables = []EmailTemplateVariable{
|
||||
SampleDE: "HLC", SampleEN: "HLC"},
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — variables consumed by the Add-User welcome
|
||||
// mail. UserService.AdminCreateUserFull populates these at send time.
|
||||
var addUserWelcomeVariables = []EmailTemplateVariable{
|
||||
{Name: ".InviterName", Type: "string",
|
||||
Description: "Anzeigename der/des global_admin, die das Konto angelegt hat.",
|
||||
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
|
||||
{Name: ".InviterEmail", Type: "string",
|
||||
Description: "E-Mail-Adresse der/des global_admin.",
|
||||
SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
|
||||
{Name: ".ToEmail", Type: "string",
|
||||
Description: "Empfänger:in (E-Mail der neuen Person).",
|
||||
SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
|
||||
{Name: ".MagicLink", Type: "string",
|
||||
Description: "Einmaliger Supabase-Recovery-Link zum Passwort-Setzen.",
|
||||
SampleDE: "https://supabase.paliad.de/auth/v1/verify?token=…",
|
||||
SampleEN: "https://supabase.paliad.de/auth/v1/verify?token=…"},
|
||||
{Name: ".BaseURL", Type: "string",
|
||||
Description: "Öffentliche Paliad-URL (PALIAD_BASE_URL).",
|
||||
SampleDE: "https://paliad.de", SampleEN: "https://paliad.de"},
|
||||
{Name: ".Firm", Type: "string",
|
||||
Description: "Firmenname (FIRM_NAME).",
|
||||
SampleDE: "HLC", SampleEN: "HLC"},
|
||||
}
|
||||
|
||||
var deadlineDigestVariables = []EmailTemplateVariable{
|
||||
{Name: ".Slot", Type: "string",
|
||||
Description: "Trigger-Slot: \"morning\" oder \"evening\". Body verwendet typischerweise .IsEvening.",
|
||||
|
||||
@@ -173,6 +173,53 @@ func TestRenderTemplateInvitation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTemplateAddUserWelcome — t-paliad-223 Slice B (#49). Catches
|
||||
// a typo in either add_user_welcome.{de,en}.html: the rendered body must
|
||||
// contain the inviter, the magic-link, the firm name, and the localised
|
||||
// fallback subject from defaultSubjects must look right.
|
||||
func TestRenderTemplateAddUserWelcome(t *testing.T) {
|
||||
svc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewMailService: %v", err)
|
||||
}
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
t.Run(lang, func(t *testing.T) {
|
||||
subject, html, err := svc.RenderTemplate(TemplateData{
|
||||
Lang: lang,
|
||||
Name: EmailTemplateKeyAddUserWelcome,
|
||||
Data: map[string]any{
|
||||
"InviterName": "Maria Schmidt",
|
||||
"InviterEmail": "maria@hlc.com",
|
||||
"ToEmail": "neu.kollege@hlc.de",
|
||||
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
|
||||
"BaseURL": "https://paliad.de",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderTemplate: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Maria Schmidt", "neu.kollege@hlc.de",
|
||||
"https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
|
||||
"https://paliad.de/login",
|
||||
// {{.Firm}} placeholder must render — branding default is "HLC".
|
||||
"HLC",
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("[%s] rendered html missing %q", lang, want)
|
||||
}
|
||||
}
|
||||
wantSubject := "[Paliad] Ihr Paliad-Konto ist bereit"
|
||||
if lang == "en" {
|
||||
wantSubject = "[Paliad] Your Paliad account is ready"
|
||||
}
|
||||
if subject != wantSubject {
|
||||
t.Errorf("[%s] subject got %q, want %q", lang, subject, wantSubject)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure
|
||||
// carries both the text and HTML parts — an earlier refactor dropped one
|
||||
// part by mistake, caught by this.
|
||||
|
||||
242
internal/services/supabase_admin.go
Normal file
242
internal/services/supabase_admin.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Package services — SupabaseAdminService — thin HTTP client for the
|
||||
// privileged Supabase Admin API endpoints.
|
||||
//
|
||||
// t-paliad-223 Slice B (#49) — the new "Add User" path on /admin/team needs
|
||||
// to create an auth.users row before inserting paliad.users (paliad.users.id
|
||||
// is FK-constrained to auth.users.id). The Supabase JS / Go client library
|
||||
// would be overkill for the three calls we actually make; this file is
|
||||
// ~150 LoC of plain net/http instead.
|
||||
//
|
||||
// Only three Admin-API calls are exercised here:
|
||||
//
|
||||
// - POST {SUPABASE_URL}/auth/v1/admin/users
|
||||
// Create an auth.users row with email_confirm=true so the user can log
|
||||
// in via a recovery link without going through the email-confirm step.
|
||||
//
|
||||
// - POST {SUPABASE_URL}/auth/v1/admin/generate_link
|
||||
// Mint a recovery link for the new user; paliad emails it via the
|
||||
// existing MailService template (NOT Supabase's default mail) so the
|
||||
// welcome message stays paliad-branded.
|
||||
//
|
||||
// - DELETE {SUPABASE_URL}/auth/v1/admin/users/{id}
|
||||
// Best-effort rollback when the paliad.users insert fails after the
|
||||
// auth.users row has been created. Failure here just leaves an
|
||||
// unonboarded auth.users row that "Onboard existing" can recover.
|
||||
//
|
||||
// All requests carry the service-role key in BOTH the `apikey` header AND
|
||||
// the `Authorization: Bearer` header — Supabase's PostgREST gateway checks
|
||||
// the former, the auth admin handlers check the latter.
|
||||
//
|
||||
// SECURITY: SUPABASE_SERVICE_ROLE_KEY is one of the most-privileged
|
||||
// credentials in the deploy. It must NEVER be sent to the browser or
|
||||
// logged. Storage is Dokploy secret, age-encrypted at rest.
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Sentinel errors. Handlers map these to HTTP status codes.
|
||||
var (
|
||||
// ErrSupabaseAdminUnavailable signals SUPABASE_SERVICE_ROLE_KEY is unset.
|
||||
// Handlers map to 503 — the Add-User path is the only feature that
|
||||
// requires it; everything else keeps working.
|
||||
ErrSupabaseAdminUnavailable = errors.New("supabase admin api unavailable (SUPABASE_SERVICE_ROLE_KEY not set)")
|
||||
// ErrSupabaseEmailExists is returned by CreateAuthUser when the email
|
||||
// already exists in auth.users. Handlers map to 409 with a nudge to
|
||||
// use "Onboard existing".
|
||||
ErrSupabaseEmailExists = errors.New("auth.users row already exists for this email")
|
||||
)
|
||||
|
||||
// SupabaseAdminClient is the thin HTTP client. Constructed once at server
|
||||
// boot; the embedded *http.Client is reused for connection pooling.
|
||||
//
|
||||
// Enabled() reports whether SUPABASE_SERVICE_ROLE_KEY is configured. When
|
||||
// it isn't, every call returns ErrSupabaseAdminUnavailable so the rest of
|
||||
// the boot path stays runnable for deployments that don't need Add-User.
|
||||
type SupabaseAdminClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewSupabaseAdminClient wires the client. supabaseURL is required (already
|
||||
// validated at boot for the anon-key flow); serviceRoleKey may be empty.
|
||||
//
|
||||
// Timeout is 10s — Supabase Admin API calls are normally sub-second; 10s
|
||||
// is forgiving enough for cold starts on a slow network but short enough
|
||||
// that a hung call doesn't block the admin UI indefinitely.
|
||||
func NewSupabaseAdminClient(supabaseURL, serviceRoleKey string) *SupabaseAdminClient {
|
||||
return &SupabaseAdminClient{
|
||||
baseURL: strings.TrimRight(supabaseURL, "/"),
|
||||
apiKey: strings.TrimSpace(serviceRoleKey),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the client has a service-role key to use.
|
||||
func (c *SupabaseAdminClient) Enabled() bool {
|
||||
return c != nil && c.apiKey != ""
|
||||
}
|
||||
|
||||
// CreateAuthUser creates an auth.users row with email_confirm=true and no
|
||||
// password (the new user signs in via the recovery link emailed later).
|
||||
// Returns the new auth.users.id.
|
||||
//
|
||||
// 422 from Supabase typically means "email already exists" — mapped to
|
||||
// ErrSupabaseEmailExists so the handler nudges the admin to "Onboard
|
||||
// existing" instead.
|
||||
func (c *SupabaseAdminClient) CreateAuthUser(ctx context.Context, email string) (uuid.UUID, error) {
|
||||
if !c.Enabled() {
|
||||
return uuid.Nil, ErrSupabaseAdminUnavailable
|
||||
}
|
||||
body := map[string]any{
|
||||
"email": strings.ToLower(strings.TrimSpace(email)),
|
||||
"email_confirm": true,
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/users", body, &resp)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
|
||||
// Supabase returns 422 (or sometimes 400 with "already registered"
|
||||
// in the body) when the email is taken. Lower-case-match the
|
||||
// substring so we catch both casings.
|
||||
if strings.Contains(strings.ToLower(string(raw)), "already") {
|
||||
return uuid.Nil, ErrSupabaseEmailExists
|
||||
}
|
||||
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
|
||||
}
|
||||
id, err := uuid.Parse(resp.ID)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("supabase admin create user: parse id %q: %w", resp.ID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GenerateRecoveryLink mints a one-time recovery link for an existing
|
||||
// auth.users row. The action_link is what we email; clicking it lands the
|
||||
// user on Supabase's password-reset page (which redirects to paliad.de
|
||||
// after the user picks a password).
|
||||
//
|
||||
// The link type is "recovery" rather than "magiclink" so the user is forced
|
||||
// to set a password — paliad doesn't support passwordless sign-in today.
|
||||
func (c *SupabaseAdminClient) GenerateRecoveryLink(ctx context.Context, email string) (string, error) {
|
||||
if !c.Enabled() {
|
||||
return "", ErrSupabaseAdminUnavailable
|
||||
}
|
||||
body := map[string]any{
|
||||
"type": "recovery",
|
||||
"email": strings.ToLower(strings.TrimSpace(email)),
|
||||
}
|
||||
var resp struct {
|
||||
ActionLink string `json:"action_link"`
|
||||
Properties struct {
|
||||
ActionLink string `json:"action_link"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/generate_link", body, &resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return "", fmt.Errorf("supabase admin generate_link: status=%d body=%s", status, string(raw))
|
||||
}
|
||||
// Supabase has historically returned the link in both shapes (top-level
|
||||
// and nested under properties). Accept either.
|
||||
if resp.ActionLink != "" {
|
||||
return resp.ActionLink, nil
|
||||
}
|
||||
if resp.Properties.ActionLink != "" {
|
||||
return resp.Properties.ActionLink, nil
|
||||
}
|
||||
return "", fmt.Errorf("supabase admin generate_link: response missing action_link: %s", string(raw))
|
||||
}
|
||||
|
||||
// DeleteAuthUser removes an auth.users row by id. Best-effort rollback
|
||||
// after the paliad.users insert has failed. A failure here is logged but
|
||||
// doesn't propagate to the caller — the row can be cleaned up later via
|
||||
// "Onboard existing" or the admin UI.
|
||||
func (c *SupabaseAdminClient) DeleteAuthUser(ctx context.Context, id uuid.UUID) error {
|
||||
if !c.Enabled() {
|
||||
return ErrSupabaseAdminUnavailable
|
||||
}
|
||||
status, raw, err := c.do(ctx, "DELETE", "/auth/v1/admin/users/"+id.String(), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("supabase admin delete user: status=%d body=%s", status, string(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// do is the shared request helper. Returns (status, raw_body, err). When
|
||||
// `out` is non-nil and the response is 2xx with a JSON body, decodes into
|
||||
// it; raw_body is still returned so the caller can inspect error responses.
|
||||
func (c *SupabaseAdminClient) do(ctx context.Context, method, path string, payload any, out any) (int, []byte, error) {
|
||||
var rdr io.Reader
|
||||
if payload != nil {
|
||||
buf, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("marshal %s body: %w", path, err)
|
||||
}
|
||||
rdr = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("build %s request: %w", path, err)
|
||||
}
|
||||
if rdr != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("apikey", c.apiKey)
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("read %s response: %w", path, err)
|
||||
}
|
||||
if out != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return resp.StatusCode, raw, fmt.Errorf("decode %s response: %w", path, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, raw, nil
|
||||
}
|
||||
|
||||
// LoadSupabaseAdminClient reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY
|
||||
// from the environment and returns a client. The key is optional — when
|
||||
// unset the client still wires (so dependents don't panic on nil-deref)
|
||||
// but every call short-circuits with ErrSupabaseAdminUnavailable so the
|
||||
// server boot stays runnable.
|
||||
func LoadSupabaseAdminClient() *SupabaseAdminClient {
|
||||
return NewSupabaseAdminClient(
|
||||
os.Getenv("SUPABASE_URL"),
|
||||
os.Getenv("SUPABASE_SERVICE_ROLE_KEY"),
|
||||
)
|
||||
}
|
||||
154
internal/services/supabase_admin_test.go
Normal file
154
internal/services/supabase_admin_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Unit tests for the Supabase admin HTTP client. The client is a thin
|
||||
// shim over net/http; coverage lives at the wire-shape level: header
|
||||
// presence, request method, body decode, status-code → error mapping.
|
||||
// No live Supabase call — every test runs against an httptest.Server.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestSupabaseAdminClient_Disabled(t *testing.T) {
|
||||
c := NewSupabaseAdminClient("https://example.invalid", "")
|
||||
if c.Enabled() {
|
||||
t.Fatal("Enabled() must be false when service-role key is empty")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := c.CreateAuthUser(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
|
||||
t.Errorf("CreateAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
|
||||
}
|
||||
if _, err := c.GenerateRecoveryLink(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
|
||||
t.Errorf("GenerateRecoveryLink must return ErrSupabaseAdminUnavailable, got %v", err)
|
||||
}
|
||||
if err := c.DeleteAuthUser(ctx, uuid.New()); !errors.Is(err, ErrSupabaseAdminUnavailable) {
|
||||
t.Errorf("DeleteAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupabaseAdminClient_CreateAuthUser_Happy pins the wire-shape:
|
||||
// POST /auth/v1/admin/users, JSON body with email_confirm=true, both
|
||||
// apikey + Authorization headers present, parses the response id.
|
||||
func TestSupabaseAdminClient_CreateAuthUser_Happy(t *testing.T) {
|
||||
wantID := uuid.New()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/auth/v1/admin/users" {
|
||||
t.Errorf("path = %q, want /auth/v1/admin/users", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("apikey") != "service-key" {
|
||||
t.Errorf("missing apikey header")
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer service-key" {
|
||||
t.Errorf("missing Bearer header")
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["email"] != "x@hlc.com" {
|
||||
t.Errorf("email = %v, want x@hlc.com", got["email"])
|
||||
}
|
||||
if got["email_confirm"] != true {
|
||||
t.Errorf("email_confirm = %v, want true", got["email_confirm"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": wantID.String()})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewSupabaseAdminClient(srv.URL, "service-key")
|
||||
gotID, err := c.CreateAuthUser(context.Background(), " X@HLC.COM ")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuthUser: %v", err)
|
||||
}
|
||||
if gotID != wantID {
|
||||
t.Errorf("id = %s, want %s", gotID, wantID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupabaseAdminClient_CreateAuthUser_EmailExists pins the 422-with-
|
||||
// "already" body → ErrSupabaseEmailExists translation. Mapped to 409 by
|
||||
// the handler.
|
||||
func TestSupabaseAdminClient_CreateAuthUser_EmailExists(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`{"msg":"A user with this email address has already been registered"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewSupabaseAdminClient(srv.URL, "service-key")
|
||||
_, err := c.CreateAuthUser(context.Background(), "dup@hlc.com")
|
||||
if !errors.Is(err, ErrSupabaseEmailExists) {
|
||||
t.Fatalf("got %v, want ErrSupabaseEmailExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes — Supabase has
|
||||
// historically returned the link at top-level and nested under
|
||||
// properties. Both shapes must be accepted.
|
||||
func TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"top-level", `{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=A"}`, "https://supabase.paliad.de/auth/v1/verify?token=A"},
|
||||
{"nested", `{"properties":{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=B"}}`, "https://supabase.paliad.de/auth/v1/verify?token=B"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/auth/v1/admin/generate_link" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), `"type":"recovery"`) {
|
||||
t.Errorf("body missing type=recovery: %s", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewSupabaseAdminClient(srv.URL, "service-key")
|
||||
got, err := c.GenerateRecoveryLink(context.Background(), "x@hlc.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRecoveryLink: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("link = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupabaseAdminClient_DeleteAuthUser pins the DELETE-by-id route shape
|
||||
// + 2xx happy path; the cleanup runs after a paliad.users insert failure
|
||||
// in AdminCreateUserFull, so the round-trip needs to work even with a
|
||||
// short context window.
|
||||
func TestSupabaseAdminClient_DeleteAuthUser(t *testing.T) {
|
||||
id := uuid.New()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
t.Errorf("method = %q", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/auth/v1/admin/users/"+id.String() {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewSupabaseAdminClient(srv.URL, "service-key")
|
||||
if err := c.DeleteAuthUser(context.Background(), id); err != nil {
|
||||
t.Errorf("DeleteAuthUser: %v", err)
|
||||
}
|
||||
}
|
||||
68
internal/services/system_audit_log_service.go
Normal file
68
internal/services/system_audit_log_service.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// SystemAuditLogService is a thin write helper for paliad.system_audit_log
|
||||
// (mig 102). Each domain emits its own event_type prefix
|
||||
// (checklist.* / data_export* / …) so dashboards can group by feature.
|
||||
//
|
||||
// The audit row is best-effort INSIDE the caller's transaction — the
|
||||
// caller passes its in-flight *sqlx.Tx so the audit write rolls back
|
||||
// with the data change if anything else fails.
|
||||
type SystemAuditLogService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewSystemAuditLogService(db *sqlx.DB) *SystemAuditLogService {
|
||||
return &SystemAuditLogService{db: db}
|
||||
}
|
||||
|
||||
// ChecklistAuditEvent is the input shape for the WriteChecklistEvent
|
||||
// helper. Scope defaults to 'org' since template-level events are firm-
|
||||
// wide; instance-level events stay on paliad.project_events via the
|
||||
// existing helpers.
|
||||
type ChecklistAuditEvent struct {
|
||||
EventType string // e.g. "checklist.authored", "checklist.edited"
|
||||
ActorID uuid.UUID
|
||||
ActorEmail string // captured at write time; survives user deletion
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// WriteChecklistEvent inserts a row into paliad.system_audit_log with
|
||||
// scope='org' and scope_root=NULL. Metadata is JSON-encoded.
|
||||
func (s *SystemAuditLogService) WriteChecklistEvent(ctx context.Context, tx *sqlx.Tx, evt ChecklistAuditEvent) error {
|
||||
if evt.EventType == "" {
|
||||
return fmt.Errorf("system_audit_log: event_type required")
|
||||
}
|
||||
if evt.Metadata == nil {
|
||||
evt.Metadata = map[string]any{}
|
||||
}
|
||||
mb, err := json.Marshal(evt.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("system_audit_log marshal: %w", err)
|
||||
}
|
||||
exec := func(q string, args ...any) error {
|
||||
if tx != nil {
|
||||
_, err := tx.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
if err := exec(
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ($1, $2, $3, 'org', NULL, $4::jsonb)`,
|
||||
evt.EventType, evt.ActorID, evt.ActorEmail, string(mb),
|
||||
); err != nil {
|
||||
return fmt.Errorf("system_audit_log insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -56,8 +58,18 @@ var (
|
||||
|
||||
// UserService reads paliad.users. Writes happen via the Phase D onboarding
|
||||
// endpoint and are not exposed here yet.
|
||||
//
|
||||
// supabase + mail + baseURL are optional dependencies wired post-construction
|
||||
// via SetAddUserDeps (t-paliad-223 Slice B). They power the new "Add User"
|
||||
// path on /admin/team which creates an auth.users row directly and emails
|
||||
// a paliad-branded welcome message. Older paths (Create / AdminCreateUser /
|
||||
// AdminUpdateUser / AdminDeleteUser) do not touch these fields and stay
|
||||
// runnable when supabase admin is unwired.
|
||||
type UserService struct {
|
||||
db *sqlx.DB
|
||||
db *sqlx.DB
|
||||
supabase *SupabaseAdminClient
|
||||
mail *MailService
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewUserService wires the service to the pool.
|
||||
@@ -65,6 +77,17 @@ func NewUserService(db *sqlx.DB) *UserService {
|
||||
return &UserService{db: db}
|
||||
}
|
||||
|
||||
// SetAddUserDeps injects the dependencies needed for AdminCreateUserFull
|
||||
// (t-paliad-223 Slice B). Called from cmd/server/main.go once supabase
|
||||
// admin + mail services + base URL are known. Safe to omit when the
|
||||
// deploy doesn't need the new "Add User" path — AdminCreateUserFull will
|
||||
// return ErrSupabaseAdminUnavailable in that case.
|
||||
func (s *UserService) SetAddUserDeps(supabase *SupabaseAdminClient, mail *MailService, baseURL string) {
|
||||
s.supabase = supabase
|
||||
s.mail = mail
|
||||
s.baseURL = baseURL
|
||||
}
|
||||
|
||||
const userColumns = `id, email, display_name, office, additional_offices, practice_group,
|
||||
job_title, global_role,
|
||||
lang, email_preferences,
|
||||
@@ -584,6 +607,193 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
|
||||
return s.GetByID(ctx, authID)
|
||||
}
|
||||
|
||||
// AdminCreateFullInput is the payload for AdminCreateUserFull (t-paliad-223
|
||||
// Slice B / m/paliad#49) — the "Konto direkt anlegen" path on /admin/team.
|
||||
//
|
||||
// Unlike AdminCreateUser this path does NOT require a pre-existing
|
||||
// auth.users row: it creates that row via the Supabase Admin API before
|
||||
// inserting paliad.users in the same tx. The two-step nature means an
|
||||
// auth.users row may exist with no paliad.users row if the second step
|
||||
// fails — recovery is via "Onboard existing".
|
||||
type AdminCreateFullInput struct {
|
||||
Email string `json:"email"` // required
|
||||
DisplayName string `json:"display_name"` // required
|
||||
Office string `json:"office"` // required, validated against offices.IsValid
|
||||
JobTitle string `json:"job_title,omitempty"`
|
||||
Profession string `json:"profession,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
SendWelcomeMail bool `json:"send_welcome_mail"` // default-on at the handler layer
|
||||
// InviterID + InviterName + InviterEmail describe the global_admin
|
||||
// performing the create. Used for the welcome-email template variables
|
||||
// + the system_audit_log row. Filled by the handler from auth.uid()
|
||||
// before the call, NOT from the request body, so a malicious admin
|
||||
// can't impersonate another inviter.
|
||||
InviterID uuid.UUID `json:"-"`
|
||||
InviterName string `json:"-"`
|
||||
InviterEmail string `json:"-"`
|
||||
}
|
||||
|
||||
// AdminCreateUserFull creates both an auth.users row (via Supabase Admin
|
||||
// API) AND a paliad.users row in one operation. Returns the new
|
||||
// paliad.users row.
|
||||
//
|
||||
// Two-step flow with best-effort rollback:
|
||||
// 1. Validate input (email format, allowed-domain check happens at the
|
||||
// handler; office + profession + lang validated here).
|
||||
// 2. POST /auth/v1/admin/users → auth_id. ErrSupabaseEmailExists if taken.
|
||||
// 3. INSERT paliad.users in a tx; on failure DELETE /auth/v1/admin/users/{id}
|
||||
// to roll back.
|
||||
// 4. system_audit_log row written (best-effort; failure logged not raised).
|
||||
// 5. If SendWelcomeMail: GenerateRecoveryLink + MailService.SendTemplate
|
||||
// (best-effort; the user-create succeeds regardless).
|
||||
//
|
||||
// Returns ErrSupabaseAdminUnavailable when SUPABASE_SERVICE_ROLE_KEY is
|
||||
// unset (handler maps to 503). Returns ErrUserAlreadyOnboarded if a
|
||||
// paliad.users row exists for the same email already (defensive — should
|
||||
// be unreachable given step 2 catches the auth.users dup first).
|
||||
func (s *UserService) AdminCreateUserFull(ctx context.Context, input AdminCreateFullInput) (*models.User, error) {
|
||||
if s.supabase == nil || !s.supabase.Enabled() {
|
||||
return nil, ErrSupabaseAdminUnavailable
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(input.Email))
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
|
||||
}
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid email %q", ErrInvalidInput, input.Email)
|
||||
}
|
||||
displayName := strings.TrimSpace(input.DisplayName)
|
||||
if displayName == "" {
|
||||
return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
|
||||
}
|
||||
if !offices.IsValid(input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
|
||||
}
|
||||
jobTitle := strings.TrimSpace(input.JobTitle)
|
||||
if jobTitle == "" {
|
||||
jobTitle = "Associate"
|
||||
}
|
||||
profession := strings.TrimSpace(input.Profession)
|
||||
if profession == "" {
|
||||
profession = ProfessionAssociate
|
||||
}
|
||||
if !IsValidProfession(profession) {
|
||||
return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, profession)
|
||||
}
|
||||
lang := strings.ToLower(strings.TrimSpace(input.Lang))
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
if lang != "de" && lang != "en" {
|
||||
return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang)
|
||||
}
|
||||
|
||||
// Cheap pre-check on paliad.users — catches the rare case where
|
||||
// paliad has a row but auth.users got swept (e.g. a Supabase support
|
||||
// purge). The Admin-API call would still succeed and we'd hit a unique
|
||||
// constraint on the FK in step 3.
|
||||
var exists bool
|
||||
if err := s.db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE lower(email) = $1)`, email); err != nil {
|
||||
return nil, fmt.Errorf("pre-check email: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, ErrUserAlreadyOnboarded
|
||||
}
|
||||
|
||||
// Step 2 — auth.users via Supabase Admin API. ErrSupabaseEmailExists
|
||||
// bubbles to the handler unchanged (409 with a "use Onboard existing"
|
||||
// hint).
|
||||
authID, err := s.supabase.CreateAuthUser(ctx, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 3 — paliad.users insert with rollback. The tx-rollback only
|
||||
// reverts the paliad insert; the auth.users row needs an explicit
|
||||
// delete because it lives in a different Postgres schema and is
|
||||
// managed by Supabase's GoTrue, not our migration set.
|
||||
rollbackAuth := func() {
|
||||
// Detached context so a cancelled parent doesn't abort the cleanup.
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if delErr := s.supabase.DeleteAuthUser(cleanupCtx, authID); delErr != nil {
|
||||
// Best-effort: log + leave a recoverable orphan rather than
|
||||
// raising a new error.
|
||||
slog.Warn("admin_create_full: rollback DeleteAuthUser failed", "auth_id", authID, "err", delErr)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role, lang)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'standard', $7)`,
|
||||
authID, email, displayName, input.Office, jobTitle, profession, lang,
|
||||
); err != nil {
|
||||
rollbackAuth()
|
||||
return nil, fmt.Errorf("insert paliad.users: %w", err)
|
||||
}
|
||||
|
||||
// Step 4 — audit row. Best-effort; an audit failure shouldn't break
|
||||
// the user-create. Captured under a fresh context so the row is
|
||||
// preserved even if the request context is on the verge of timing out.
|
||||
auditCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if _, err := s.db.ExecContext(auditCtx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('user.added_by_admin', $1, $2, 'org', NULL, $3::jsonb)`,
|
||||
nullableUUID(input.InviterID), input.InviterEmail,
|
||||
fmt.Sprintf(`{"created_user_id":"%s","email":"%s","sent_welcome":%t}`,
|
||||
authID, email, input.SendWelcomeMail),
|
||||
); err != nil {
|
||||
slog.Warn("admin_create_full: audit insert failed", "auth_id", authID, "err", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
// Step 5 — welcome email. Best-effort; failure logged + returned in
|
||||
// the result so the admin can retry the recovery-link send separately.
|
||||
if input.SendWelcomeMail {
|
||||
if err := s.sendAddUserWelcome(ctx, email, lang, input); err != nil {
|
||||
slog.Warn("admin_create_full: welcome mail failed", "auth_id", authID, "err", err)
|
||||
// Surfaced as a non-fatal warning via the returned model's
|
||||
// caller-visible side channel? For v1 we just log — the
|
||||
// admin can re-send via /admin/team's "Recovery link" follow-up
|
||||
// (filed as out-of-scope in design §3).
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetByID(ctx, authID)
|
||||
}
|
||||
|
||||
// sendAddUserWelcome generates the recovery link and dispatches the
|
||||
// branded welcome email. Errors propagate so the caller can log them; the
|
||||
// caller (AdminCreateUserFull) decides whether they're fatal.
|
||||
func (s *UserService) sendAddUserWelcome(ctx context.Context, email, lang string, input AdminCreateFullInput) error {
|
||||
if s.mail == nil {
|
||||
return errors.New("mail service not wired")
|
||||
}
|
||||
link, err := s.supabase.GenerateRecoveryLink(ctx, email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate recovery link: %w", err)
|
||||
}
|
||||
baseURL := s.baseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://paliad.de"
|
||||
}
|
||||
return s.mail.SendTemplate(TemplateData{
|
||||
To: email,
|
||||
Lang: lang,
|
||||
Name: EmailTemplateKeyAddUserWelcome,
|
||||
Data: map[string]any{
|
||||
"InviterName": input.InviterName,
|
||||
"InviterEmail": input.InviterEmail,
|
||||
"ToEmail": email,
|
||||
"MagicLink": link,
|
||||
"BaseURL": baseURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUpdateInput is the payload for AdminUpdateUser. Same shape as
|
||||
// UpdateProfileInput but additionally allows the additional_offices array
|
||||
// (which the self-service settings page does not expose).
|
||||
|
||||
12
internal/templates/email/add_user_welcome.de.html
Normal file
12
internal/templates/email/add_user_welcome.de.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{{define "content"}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Willkommen bei Paliad</h1>
|
||||
<p style="margin:0 0 12px 0;">{{.InviterName}} hat ein Konto für Sie bei Paliad — der Patent-Praxis-Plattform für {{.Firm}} — 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ültig. Anschließend kö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önnen Sie sie ignorieren — ohne das Festlegen eines Passworts bleibt das Konto unbenutzbar.</p>
|
||||
{{end}}
|
||||
12
internal/templates/email/add_user_welcome.en.html
Normal file
12
internal/templates/email/add_user_welcome.en.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{{define "content"}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Welcome to Paliad</h1>
|
||||
<p style="margin:0 0 12px 0;">{{.InviterName}} has created a Paliad account for you — 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 — without setting a password the account stays unusable.</p>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user