Compare commits

..

30 Commits

Author SHA1 Message Date
m
52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00
m
99f08e3863 Merge: t-paliad-145 design doc only — local chat feature PARKED per m's call (2026-05-07 17:03). Doc preserved in docs/design-local-chat-2026-05-07.md for when it un-parks. 2026-05-07 17:04:10 +02:00
m
dd4f563212 design(t-paliad-145): local chat for teams
Inventor design for in-app messaging surface inside paliad. Three
coordinated sub-designs (surface/visibility, real-time/content/
persistence, integration with existing surfaces) answering all 21
issue-body questions plus 11 inventor follow-ups.

Recommendations:
- Per-project chat + DMs in v1; per-deadline/termin/partner-unit defer
- Per-thread visibility = can_see_project (existing predicate, derivation-aware)
- SSE for real-time (single new long-lived endpoint)
- New: paliad.chat_threads, chat_messages, chat_reads, chat_thread_participants, chat_mentions
- New project_teams.chat_access flag, default OFF for local_counsel/expert
- Approval auto-post into project chat (one auto-event class only)
- Markdown subset, @mentions, entity-refs (#frist-, #projekt-, #termin-, #approval-)
- 5-min author edit window, soft-delete by author or admin
- In-app sidebar badge in v1; PWA push + email digest deferred Phase 2
- Both top-level /chat and per-project Chat tab
- NOT a 5th source in Custom Views (chat is conversation, not events)

Trade-off flagged: adoption risk against existing Slack/WhatsApp.
Recommend m sanity-check with PA colleagues before locking v1 scope.

Migration 057. Awaiting m go/no-go before coder shift.
2026-05-07 16:03:05 +02:00
m
95f6f03cda Merge: t-paliad-144 A2 — Custom Views frontend (Meine Sichten sidebar group + /views list + /views/new editor + /views/{slug}/edit + 3 render shapes list/cards/calendar) 2026-05-07 13:16:48 +02:00
m
fdde9eb754 feat(t-paliad-144 A2): frontend Custom Views UI
Phase A2 of the data-display-model rethink. Builds on A1's API contract
(merged as cda4b40). User-visible.

What lands:

- TSX shells for /views (the view runner) and /views/new + /views/{slug}/edit
  (the editor). One TSX per page; client/views.ts + views-editor.ts
  hydrate.

- Three render-shape components in client/views/: shape-list.ts (table
  for density=comfortable, compact one-line stream for density=compact —
  the activity-feed look without a separate "activity" shape per Q4 lock-
  in 2026-05-07), shape-cards.ts (day-grouped chronological), and
  shape-calendar.ts (month grid with day-pills, mobile cards-fallback
  notice on viewports <600px per design §9 trade-off 8).

- Generic view shell that resolves a slug to a system view (via
  /api/views/system) or a user view (via /api/user-views), runs it via
  POST /api/views/{slug}/run, dispatches to the matching shape, exposes
  a 3-button shape switcher that swaps the live render without re-fetching,
  and surfaces the inaccessible-projects toast when the substrate flags
  some IDs (Q17 fail-open attribution).

- View editor with widgets for name/slug/icon, sources (4 checkboxes),
  scope mode (all_visible / my_subtree / personal_only), time horizon
  (six fixed options), shape, and list density. Slug regex enforced
  client-side mirroring the server validator. Save → POST/PATCH; delete
  → simple yes/no confirm (Q25 lock-in).

- Sidebar "Meine Sichten" group between Arbeit and Werkzeuge. Renders
  empty server-side; client/sidebar.ts.initUserViewsGroup() hydrates from
  GET /api/user-views on mount, injecting one nav item per saved view
  + an always-present "+ Neue Sicht" trailing entry. show_count=true
  views get a sidebar badge updated by a fire-and-forget run query.

- Page handlers /views (most-recently-used redirect or onboarding shell),
  /views/{slug}, /views/new, /views/{slug}/edit. All gateOnboarded.

- 91 new i18n keys (DE+EN) covering nav.group.user_views, view shell,
  shape labels, source/kind/horizon/scope vocabulary, editor form,
  empty/error/onboarding states.

- ~250 lines of CSS for the views shell, list/cards/calendar shapes,
  Meine Sichten sidebar group.

- build.ts registers views.tsx + views-editor.tsx page renderers and
  the two client bundles.

Frontend builds clean (i18n codegen 1700→1791 keys), backend builds +
vets clean, all tests pass, IIFE wrap intact on the new bundles.
2026-05-07 13:15:55 +02:00
m
cda4b4083d Merge: t-paliad-144 A1 — backend substrate + Custom Views API (migration 056 paliad.user_views + ViewService 4-source union + FilterSpec/RenderSpec validators + SystemView registry + UserViewService + 9 HTTP handlers) 2026-05-07 12:53:52 +02:00
m
b516201110 feat(t-paliad-144 A1): backend substrate + Custom Views API
Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only;
no user-visible change in A1. A2 (frontend) lands separately.

What's new:

- Migration 056: paliad.user_views table with RLS scoped to caller
  (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE
  (user_id, slug). No is_system flag — system defaults stay code-
  resident per Q8 lock-in.

- internal/services/filter_spec.go (+test): structured FilterSpec
  with Sources / Scope / Time / Predicates. Server-side validator
  rejects unknown sources, duplicate sources, conflicting scope
  modes, horizon=all without explicit projects (Q26 clamp), and
  every per-source enum (deadline.status, appointment_types,
  project_event kinds, approval_request status / viewer_role).

- internal/services/render_spec.go (+test): RenderSpec with three
  shapes (list / cards / calendar — Q4 lock-in 2026-05-07).
  Per-shape config kept separately so flipping shapes preserves
  tweaks. Validator over column / sort / density / group_by /
  default_view enums.

- internal/services/system_views.go (+test): code-resident
  SystemView definitions for dashboard / agenda / events / inbox /
  inbox-mine. Reserved-slug list (Q23) prevents user-views from
  colliding with top-level URLs. Case-folded matching.

- internal/services/view_service.go: extends EventService with
  RunSpec — runs a FilterSpec across all four substrate sources
  (deadline + appointment + project_event + approval_request)
  and merges into []ViewRow sorted by event_date. ViewRow is a
  discriminated projection (kind + common header + per-source
  Detail json.RawMessage). Q17 fail-open attribution: returns
  inaccessible_project_ids for explicit-scope queries where the
  caller can't see some IDs.

- internal/services/user_view_service.go (+test): CRUD on
  paliad.user_views — Create (server-assigns sort_order MAX+1
  in tx), GetBySlug, GetByID, Update (partial), Delete, Touch
  (last_used_at), MostRecent. Reserved-slug + slug-format
  validators on every write.

- internal/handlers/views.go: nine HTTP handlers wiring the
  endpoints (GET/POST/PATCH/DELETE /api/user-views/...,
  POST /api/user-views/{id}/touch, POST /api/views/run,
  POST /api/views/{slug}/run, GET /api/views/system).

- main.go + handlers.go + projects.go: wire UserViewService
  into the bundle; conditional route registration when both
  UserView + Event services are present.

Pure-Go tests (no DB): 32 cases pass — filter spec validators,
render spec validators, system view registry, reserved slugs.

Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases
covering create / list / get / uniqueness / update / delete /
touch / most-recent / reserved-slug / bad-slug / empty-name /
invalid-spec.

Coexists with t-139 (in-flight on noether's other branch) and
t-138 (shipped) without coordination commits — RunSpec uses the
existing visibility predicate that t-139's migration 055 will
extend with derivation. Approval-request source delegates to
ApprovalService.ListPendingForApprover / ListSubmittedByUser
(both already extended for derived_peer authority in t-139 Phase 3).

Files: 15 changed, 3134 insertions. Build clean. Tests green.
2026-05-07 12:51:37 +02:00
m
956ff10e4d design(t-paliad-144): m signed off + Q4 correction (3 shapes, not 4)
m's lock-in 2026-05-07: agree with all recommendations on Q1-Q18 and §10
Q19-Q27, with one correction on Q4: "activity" is a content selection
(sources + filters), not a render shape. Folded into `list` shape with
density: "compact" + actor/time columns. Shape ⊥ source — any source can
render in any shape.

Render shapes for v1: list / cards / calendar (3, was 4).

PR split decision (delegated to inventor): A1 backend substrate + API
(no UI change, ~1800 LoC, smoke via curl) → main → A2 frontend Custom
Views UI (~1600 LoC, additive on A1) → main.

Status flipped DRAFT → LOCKED. Inventor → coder transition initiated.
2026-05-07 12:36:05 +02:00
m
5c263102e3 design(t-paliad-144): data display model — additive Custom Views + render-shape switcher
Inventor pass on m's data-display rethink (m/paliad#5). Three coordinated
sub-designs in one doc, scoped to m's locked direction (additive, subsume
unified inbox, sidebar Meine Sichten group, in-page render-shape switcher,
paliad-only).

Recommended substrate: 4-source ViewService (deadline + appointment +
project_event + approval_request) returning discriminated ViewRow.
Recommended filter grammar: structured JSON spec with server-side validator.
Recommended render shapes for v1: list / cards / calendar / activity (defer
kanban, connections-graph, distinct-timeline). Recommended persistence:
new paliad.user_views table (migration 056), RLS-bounded to caller, code-
resident system defaults (no is_system flag).

Phasing: Phase A ships substrate + Custom Views standalone (~3400 LoC,
no system page changes); Phase B refactors /agenda/events/inbox/dashboard
internals onto the substrate later. Coexists transparently with t-139
(hierarchy aggregation in flight on noether's other branch) and t-138
(approvals shipped).

27 questions answered (18 from issue body §1–§5 + 9 inventor follow-ups
in §10 for m's call before coder shift).
2026-05-06 17:25:44 +02:00
m
f44ee0af0f Merge: t-paliad-143 — derived members per-unit role + multi-unit Herkunft (admin UI dropdown + array_agg in DerivationService) 2026-05-06 17:17:05 +02:00
m
bfc48b1420 fix(t-paliad-143): derived team members all show 'Attorney' + Herkunft collapses multi-unit users
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":

1. **All derived members labeled 'Attorney'.** Migration 055 added
   partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
   the column in the admin UI. So 100% of pum rows are 'attorney' and
   Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
   surfaces every member as 'attorney' even when they're really PAs.

2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
   ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
   wins, every other unit-membership dropped. Judith Molarinho Vaz +
   Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.

**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
  UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
  all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
  bool_or(derive_grants_authority), GROUP BY user. One row per user, every
  (unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
  doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
  directly into the Go struct. Pinned by unit test.

**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
  (unit, role) source — single-unit users render as before
  ("über: **Lehment** [Sicht]"); multi-unit users render
  "über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.

**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
  change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
  already shipped in t-paliad-139 Phase 2). Disables during request,
  rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
  admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.

**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).

**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
  returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
  and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
  unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).

After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
2026-05-06 17:16:17 +02:00
m
5cb7f76160 Merge: t-paliad-142 — sidebar no longer slide-in animates on every page nav when pinned (parallel :root.sidebar-pinned CSS selectors) 2026-05-06 16:56:14 +02:00
m
8b76d0c8fa fix(t-paliad-142): sidebar slide-in on every page nav when pinned
The FOUC script in PWAHead.tsx sets `<html class="sidebar-pinned">`
pre-paint, which kept body padding correct from frame 1 — but the
.sidebar element's own width keyed off `.sidebar.pinned` (set by
initSidebar in DOMContentLoaded). That made every navigation paint
the rail at collapsed width, then animate width 150ms → pinned width
once JS ran. Visible slide-in on Dashboard / Agenda / Projekte / etc.

Fix: extend every `.sidebar.pinned` rule with a parallel
`:root.sidebar-pinned .sidebar ...` selector so the html-class set
pre-paint is sufficient to render the full pinned visual state from
frame 1 (width, label opacity, pin/resize/badge visibility, search
input). Runtime initSidebar still mirrors `.pinned` onto the element
itself for explicit pin/unpin click animation. Same dual-selector
pattern already used by `.has-sidebar.sidebar-pinned` /
`:root.sidebar-pinned .has-sidebar` for body padding.

Mobile unaffected — FOUC script only sets html.sidebar-pinned when
window.innerWidth >= 1024, and initSidebar clears it on resize.
2026-05-06 16:55:30 +02:00
m
9cd05e7c59 Merge: fix team-add suggestions dropdown floating off-screen (parent needed position:relative) 2026-05-06 16:54:22 +02:00
m
5598aef074 fix(t-paliad-141): collab-suggestions dropdown floats off-screen — parent .form-field had no position. Scope :has() rule sets parent to position:relative only where the dropdown actually exists 2026-05-06 16:54:22 +02:00
m
16fe5763f3 Merge: fix /inbox sidebar — call initSidebar() so pin restoration + hover work like every other page 2026-05-06 16:50:21 +02:00
m
18faf81f58 fix(t-paliad-138): /inbox missing initSidebar() call — sidebar JS never ran on inbox page (pin restoration, hover-expand, badge polling all dead). One-liner add to inbox.ts 2026-05-06 16:50:21 +02:00
m
aeaba66892 Merge: t-paliad-139 Phase 2+3 — partner-unit derivation schema (migration 055) + DerivationService + Team-tab subsections + derived_peer approval-authority extension 2026-05-06 16:47:36 +02:00
m
a61c1490e3 feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.

Schema (migration 055 update)
-----------------------------
  - paliad.approval_requests.decision_kind CHECK extended to accept
    'derived_peer'. Down migration restores the t-138 two-value CHECK.
    Live SQL dry-run confirmed the new value is accepted.

Service layer
-------------
  - approval_levels.go: new constant DecisionKindDerivedPeer.
  - approval_service.go (4 sites widened with the derivation EXISTS branch):
      1. canApprove — third resolution step after global_admin + direct/
         ancestor team membership: matches partner-unit-derived members
         on path with derive_grants_authority=true and a unit_role whose
         approval_role_from_unit_role mapping meets the threshold.
         Returns DecisionKindDerivedPeer when this branch is the one that
         passed.
      2. hasQualifiedApprover (the deadlock-check at submit time) —
         widened so a project with no direct approvers but an authority-
         granting unit attachment is still submittable.
      3. ListPendingForApprover (the /inbox query) — third UNION ALL
         branch so derived authority sees their queue.
      4. PendingCountForUser (the bell-badge query) — same widening so
         derived authority sees the count tick.
  All four queries reuse paliad.approval_role_from_unit_role(text) added
  by Phase 2 of migration 055.

Frontend
--------
  - 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
    "Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
    derived member (Partner Unit)". Verlauf rendering of the third
    decision_kind value works through the existing translateEvent /
    decision_kind switch with no other change. 1606 keys total.

Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.

Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
2026-05-06 16:45:19 +02:00
m
544bb63684 feat(t-paliad-139): Phase 2 — partner-unit derivation schema + Team-tab subsections
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):

  - paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
    CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
    distinction so derivation can target specific tiers without re-
    introducing a firm-wide rank column. The same human can be 'attorney'
    in one unit and 'lead' in another.
  - paliad.project_partner_units junction (project_id, partner_unit_id,
    derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
    DEFAULT false, attached_at, attached_by) with composite PK and RLS
    (read = can_see_project; write = global_admin OR project lead).
  - paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
    derived authority is consulted by the t-138 ladder.
  - paliad.can_see_project extended with one EXISTS branch — derivation
    walks the path: a user is visible on P if any (ancestor of P) is
    attached to a unit they are a member of with a matching unit_role.

No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.

Backend services
----------------
  - DerivationService (new, internal/services/derivation_service.go):
      AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
      ListDerivedMembers (path-walking dedupe by closest attachment),
      ListDescendantStaffed (descendant-direct rows excluding ancestor-
      already-staffed), EffectiveProjectRole (returns role + source ∈
      {direct, ancestor, derived} for the t-138 approval gate in Phase 3).
  - PartnerUnitService extensions:
      PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
      UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
      SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
      role read in tx, audit emit 'member_role_changed'. ListMembers and
      ListWithMembers SELECT projection now includes pum.unit_role.

Handlers
--------
  - GET /api/projects/{id}/partner-units              → ListAttachedUnits
  - POST /api/projects/{id}/partner-units             → AttachUnitToProject
  - DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
  - GET /api/projects/{id}/team/derived               → ListDerivedMembers
  - GET /api/projects/{id}/team/from-descendants      → ListDescendantStaffed
  - PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
  - Services bundle gains Derivation; cmd/server/main.go wires it.

Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
  - "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
  - "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
    Augen] badge per the m-locked honesty rule (§3.5).
  - "Partner Units" — attached-unit list with attach/detach controls
    (lead/admin only) and a form picker for derive_unit_roles +
    derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).

Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.

i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).

CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.

Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
2026-05-06 16:41:41 +02:00
m
2d06cdf20e Merge: t-paliad-139 Phase 1 — /projects/{id} aggregation bug fix (use projectDescendantPredicate on 3 legacy narrow methods + frontend toggle + attribution chip) 2026-05-06 16:29:14 +02:00
m
f8d8ea591d Merge remote-tracking branch 'origin/main' into mai/noether/inventor-project 2026-05-06 16:26:46 +02:00
m
77d664c5cc Merge: fix inbox sidebar collapse — drop duplicate app.js include 2026-05-06 16:24:36 +02:00
m
8cf95761d0 fix(t-paliad-138): drop double-include of /assets/app.js on /inbox — PWAHead already injects it; the duplicate ran sidebar.ts twice and collapsed the sidebar on navigation 2026-05-06 16:24:36 +02:00
m
d41fc49809 feat(t-paliad-139): Phase 1 — /projects/{id} aggregation bug fix
m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.

Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.

Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
  default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
  WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
  WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
  LEFT JOIN paliad.projects to surface project_title for the Verlauf
  attribution chip on /projects/{id}. Inner subquery aliased pp to
  avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
  enrichment. Other readers leave it nil and the JSON serialiser omits
  it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
  ?direct_only=true|false and passes through to the service. New
  handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
  DeadlineService.ListForProject + AppointmentService.ListForProject
  + ProjectService.ListEvents (live-DB test, skipped without
  TEST_DATABASE_URL).

Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
  /api/projects/{id}/deadlines + /appointments (legacy narrow) to
  /api/events?type=deadline|appointment&project_id={id} (the union
  endpoints, already aggregating + enriching with project_title). The
  Verlauf still uses /api/projects/{id}/events but with the new
  direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
  subtree (true). persistSubtreeMode replaceState keeps back-button
  friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
  Appointments sections. Shared state across the three; clicking any
  toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
  Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
  Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
  project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
  Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
  Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
  .aggregation-chip — small additive styling.

No schema. No migration. Phases 2 + 3 stack on top per design §7.
2026-05-06 16:24:31 +02:00
m
1eebf2fc44 Merge: t-paliad-141 — project team-add autocomplete fix + invite-new-user inline flow 2026-05-06 16:22:41 +02:00
m
fb1a709bb8 fix(t-paliad-141): project team-add autocomplete + invite-new-user inline flow
Root cause: `.collab-suggestions` had `display: none` in CSS but no JS site
ever toggled it back on. Suggestions rendered into a permanently hidden div.
Bug originated when the akten-collab-* pattern was renamed and copied for
project team-add and partner-units member-add — the original akten-neu.ts
toggled `style.display`, but the copies relied on innerHTML alone.

Fix: switch to content-driven visibility — `.collab-suggestions:not(:empty)
{ display: block }`. No JS changes needed at consumer sites; fixes all three
broken pickers (project team-add, project parent picker, partner-units member-
add) at once. Added missing styling for `.collab-suggestion` items (padding,
hover, separators) — they were unstyled even when visible.

Plus: invite-new-user inline affordance on project /team. When the typed
query matches zero existing users, a "Benutzer nicht gefunden? Einladen"
row appears below the dropdown. Click opens the existing global invite modal
(sidebar-invite-btn → /api/invite) and pre-fills the email if the query
looks like one. No new backend, no new modal — reuses what /admin/team and
the sidebar already use.
2026-05-06 16:21:53 +02:00
m
e2e1381395 Merge: t-paliad-138 — dual-control approvals (4-eye principle) — migration 054 + ApprovalService + inbox/bell + pending pills + CalDAV PENDING + Verlauf integration 2026-05-06 16:10:29 +02:00
m
2247c0707d docs(t-paliad-139): design lock — m signed off on all 19 §6 recommendations
m's go/no-go pass at 2026-05-06 15:58: "I agree with all your recommendations
- go." All 19 questions in §6 lock as the recommended answers verbatim.

§0 status flipped from READY-FOR-REVIEW to LOCKED. New "Locked m decisions
on §6" subsection captures the highlights inline so future readers don't
have to scan the whole table to know what's pinned.

§13 end-of-design line updated to reflect the lock.

Implementation phasing (§7) unchanged:
- Phase 1: bug fix on the 3 narrow service methods (no schema, ~400 LoC,
  ships standalone, closes the user-visible /projects/{id} "Keine Fristen"
  bug).
- Phase 2: migration 055 (partner_unit_members.unit_role,
  project_partner_units, extended can_see_project()) + DerivationService +
  frontend Team-tab subsections + /admin/partner-units unit_role tagging
  + project /settings/team Partner Units section. Independent of t-138.
- Phase 3: approval extension — canApprove + inbox SQL widening for
  derived_peer decision_kind. Gates on cronus's t-138 (currently on
  mai/cronus/inventor-dual-control @ b3401ec) landing on main.

Inventor parked. Awaiting head's coder-shift assignment.
2026-05-06 15:59:37 +02:00
m
6c41550945 docs(t-paliad-139): inventor design — hierarchy aggregation + effective team + PA derivation
Three coordinated sub-designs in one doc, scoped to m's locked constraints
(2026-05-06):

1. Surface-by-surface aggregation policy. Bug surface fix:
   /projects/{client_id} renders "Keine Fristen" because
   DeadlineService.ListForProject + AppointmentService.ListForProject +
   ProjectService.ListProjectEvents all WHERE project_id=$1 exact-match
   instead of walking paliad.projects.path descendants. The shipped t-124
   contract (projectDescendantPredicate, deadline_service.go:133 etc.)
   already aggregates correctly on the union endpoints — three legacy
   narrow paths just bypass it. Per-surface decision table for events /
   deadlines / termine / Verlauf / project tree counts / dashboard /
   CalDAV / email / search.

2. Effective-team semantics. Three structural gaps in the issue's
   premise (verified against schema):
   - No project↔unit junction (partner_unit involvement on a project).
   - No PA/lawyer distinction in partner_unit_members (no role column).
   - No lawyer↔PA pairing anywhere — Q11's "where is it stored" → nowhere.
   Proposes:
   - paliad.partner_unit_members.unit_role (lead|attorney|senior_pa|pa|paralegal),
     unit-scoped not firm-wide so 3-axis principle holds.
   - paliad.project_partner_units junction with derive_unit_roles[]
     (default {pa, senior_pa}) + derive_grants_authority bool.
   - Compute-on-read derivation via extended can_see_project() — no
     materialised state, no drift.
   - Display-effective vs visibility-effective team are different sets;
     rename ListEffectiveMembers to ListVisibilityEffectiveMembers + add
     ListSubtreeMembers.

3. Approval policy × hierarchy × derivation. Coordinates with t-138
   (cronus, mai/cronus/inventor-dual-control @ 7d1ddb9):
   - Q10: keep cronus's no-auto-inheritance, harden UX with a "Eltern-
     Politik (zur Information)" panel showing parent rules without
     applying.
   - Q12: derived members visibility-only by default; per-(project, unit)
     opt-in flag derive_grants_authority. When opted in, decision_kind
     extends with derived_peer for honest audit chronology.
   - canApprove + inbox SQL extension shape spec'd; coordinates with
     cronus's t-138 §3.4 / §7.4.

Locked m decisions surfaced in §0:
- Behaviour is surface-specific.
- Effective Team of a Client = direct ∪ descendants ∪ partner-unit-derived.
- PA derivation = unit-on-project trigger.
- Derivation honesty: annotated everywhere.
- paliad-only scope.

19 design questions with proposed answers in §6 for m to lock. Migration
055 specced (§5). Implementation phased into 3 PRs (§7) — Phase 1 bug fix
ships standalone if m wants quick win.

Inventor parked. Awaiting m go/no-go before coder shift.
2026-05-06 15:38:41 +02:00
69 changed files with 13001 additions and 74 deletions

View File

@@ -158,6 +158,9 @@ func main() {
Link: services.NewLinkService(pool),
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
Approval: services.NewApprovalService(pool, users),
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).

View File

@@ -0,0 +1,888 @@
# Design: Data display model — additive Custom Views layer + unified inbox subsume + render-shape switcher
**Task:** t-paliad-144
**Issue:** m/paliad#5
**Author:** noether (inventor)
**Date:** 2026-05-06
**Status:** LOCKED 2026-05-07 — m signed off on all recommendations + §10 follow-ups, with one correction (Q4 narrowed from 4 shapes → 3; "activity" is a filter/source choice, not a render shape — folded into `list` shape with density config). Inventor → coder transition initiated. PR split chosen: A1 backend substrate, A2 frontend Custom Views.
**Branch:** `mai/noether/inventor-data-display`
**Builds on:** t-paliad-109 (events unification, shipped) + t-paliad-138 (approvals, shipped) + t-paliad-139 (hierarchy aggregation, all 3 phases on `mai/noether/inventor-project` awaiting merge gate)
---
## 0. Premise check (read this first)
The issue body asks for a unified data-display model. Three premises in the brief that I verified against the live tree on this worktree before designing on top of them:
| Premise | Live state | Verdict |
|---|---|---|
| `EventService` is already a 2-source union over `paliad.deadlines` + `paliad.appointments` | `internal/services/event_service.go` lines 40193 — `ListVisibleForUser` runs the deadline path then the appointment path then merges in Go, sorted by `event_date` | **confirmed**; substrate exists in miniature today |
| `/agenda` is a separate timeline service, not the same code path | `internal/services/agenda_service.go` lines 78128 — `AgendaService.List` independently joins deadlines + appointments. Different SQL, different projection (`AgendaItem` vs `EventListItem`), different urgency annotation. | **confirmed**; we have *two* substrates already, both 2-source. Generalising means picking one and retiring the other (or keeping both temporarily). |
| `/inbox` is a 4-eye approval surface, not a generic activity feed | `frontend/src/inbox.tsx` (61 lines) + `internal/services/approval_service.go` lines 730810 — two-tab UI ("Zur Genehmigung", "Meine Anfragen") backed by `ListPendingForApprover` / `ListSubmittedByUser`. | **confirmed**; today's `/inbox` is approval-only, not the unified-inbox concept m's brainstorm describes |
| t-paliad-139 Phase 2 schema (migration 055) is incoming but not on main | Migration file exists at `internal/db/migrations/055_hierarchy_aggregation.up.sql`; per noether's prior memory, all 3 phases are stacked on `mai/noether/inventor-project` awaiting merge gate. | **confirmed**; this design must compose on top of 055's `paliad.project_partner_units` + `derive_grants_authority` model without forcing 139 to re-land |
| `paliad.project_events` carries audit kinds (`project_created`, `status_changed`, `project_archived`, `project_reparented`, …) | `internal/services/project_service.go` lines 491805 — five `insertProjectEvent` call-sites today; `event_type` column is free-text. | **confirmed**; `project_events` is the natural fourth data source for "what happened on my projects?" |
So the premises that anchor the design are sound. One correction to the issue body itself worth flagging:
> the issue body lists `paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.approval_requests` as the four current data tables.
That is right, but `event_service.go` only unions the **first two**. The Verlauf surface on `/projects/{id}` (project_events) and the inbox surface (approval_requests) are *each* their own bespoke endpoint today. The design below makes all four first-class `data_source` values in the substrate; flagging that the existing `EventService` will need to grow, not stay frozen.
---
## 1. m's intent (as I read it)
> "Custom views with saving them. […] If they could customize their view like 'myVerySpecialAgenda' with criteria and view options (filters, type of view — calendar vs cards vs list) and turn on parts — and then those views would be shown in the sidenavbar under a separate button. And on the page, the user can select all kinds of visuals."
Plus the locked direction of 2026-05-06 16:42:
- **Additive.** Fixed defaults stay; Custom Views ship alongside.
- **Subsume the unified inbox.** Approval candidates + project activity + new cases + status changes — all viewable through the same substrate, with configurable granularity.
- **Sidebar layout:** separate "Meine Sichten" group.
- **In-page render-shape switcher.**
- **paliad-only scope.**
Three design pieces fall out of this:
1. **A substrate** — one read API that returns rows from N data sources, filterable by one shared grammar.
2. **A render layer** — a small set of presentation components (List, Cards, Calendar, Activity) that all consume the substrate's row shape.
3. **A persistence + sidebar story**`paliad.user_views` + a "Meine Sichten" group + URL contract `/views/{slug}`.
§§35 cover those three. §6 covers cross-cutting concerns (RLS, performance, migration). §10 lists open questions for m to answer before coder shift.
---
## 2. Recommended design (TL;DR)
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|---|---|---|
| **Substrate shape** | One `ViewService` (new) that union-loads from 4 data sources: `deadline`, `appointment`, `project_event` (audit), `approval_request`. Returns a discriminated `[]ViewRow` keyed by `kind`. | Single virtual SQL `view_row` table with UNION ALL across all 4 — too many polymorphic columns; harder to evolve per-source filters. |
| **Filter grammar** | Structured JSON spec validated server-side (`FilterSpec`). UI builds it via affordance widgets; the JSON is also human-editable for power users. | SQL DSL (security risk + complexity); UI-only (forces every dimension to have a widget). |
| **Render shapes for v1** | `list`, `cards`, `calendar` (3). Activity-feed appearance is achieved by source/filter choice (`sources: ["project_event", …]`) rendered through `list` shape with `density: "compact"` + actor/time columns — *not* a separate shape. Defer `kanban`, `connections-graph`, `timeline-distinct-from-cards`. | Ship 4+ shapes including a dedicated "activity" — m's correction (2026-05-07): activity is content selection, not visualisation. Shape ⊥ source. |
| **Persistence** | New table `paliad.user_views` (id, user_id, slug, name, filter_spec jsonb, render_spec jsonb, sort_order, icon, last_used_at, …). RLS = caller's own rows only. | Per-user JSON column on `paliad.users` — kills the sidebar count badge query path (`SELECT count(*) WHERE user_id`); also no indexed sort. |
| **System defaults — code or DB?** | **Code.** Defaults stay as their own pages (`/dashboard`, `/agenda`, `/events`, `/inbox`); they are *built using the same render components* the custom-view system uses. No `is_system=true` row in `user_views`. | Seed system rows per user — drifts on schema bumps; new users miss bumps; `is_system=true` is a synonym for "config-as-data when config-as-code is cleaner". |
| **Sidebar** | New "Meine Sichten" group between "Arbeit" and "Werkzeuge". Each saved view appears as one nav entry (icon + name). One trailing "+ Neue Sicht" entry. | "Meine Sichten" as a single sidebar entry expanding to a panel — extra click cost on every navigation. |
| **In-page render-shape switcher** | A 4-button switcher on every view page (system + custom). Same component already exists on `/events` (cards/list/calendar). Generalise + add `activity`. | Per-route hardcoded shape — fights m's intent ("user can select all kinds of visuals"). |
| **URL contract** | `/views/{slug}` for custom views (slug is user-scoped). System views keep their existing URLs. Filter overrides via query params, transient (don't mutate stored spec). | UUID URLs (`/views/{uuid}`) — unsharable, unbookmarkable. |
| **`/inbox` page** | Stays as a fixed sidebar entry at the same URL. **Internally** refactored to use the new substrate as its read path, but the UI + URL stay. | Refactor /inbox away — needless break for users + email links. The locked direction is "subsume the inbox concept", which I read as substrate sharing, not URL retirement. |
| **Approval-candidate visibility** | Approval requests are their own `data_source`; an inbox-shaped view picks `sources: ["approval_request"]`. Pending pills on entity rows are a separate concern (already shipped via `entity.approval_status='pending'`). | Predicate-only — collapses two genuinely-different shapes (the request row vs the entity row). |
| **Migration / coexistence** | **Phase A:** ship substrate + render components + Custom Views + `paliad.user_views`. Existing pages untouched. **Phase B (later, separate task):** refactor system pages internally to use the substrate. | Refactor system pages in the same PR — bigger blast radius; harder to roll back. |
| **Performance v1** | Run on every load. Cursor pagination (`event_date` + `id` tiebreaker). No materialised views. Add per-source row caps later if telemetry says so. | Materialised view per saved view — refresh complexity, drift risk, doesn't help the first load. |
The rest of this doc is the detail behind those rows.
---
## 3. Section A — Substrate: data sources + filter grammar (Q1Q3, Q13)
### Q1 — What's the fundamental row?
**Recommendation: discriminated `ViewRow` projection over an explicit data-source registry.**
```go
// internal/services/view_service.go (new)
type DataSource string
const (
SourceDeadline DataSource = "deadline"
SourceAppointment DataSource = "appointment"
SourceProjectEvent DataSource = "project_event" // audit / Verlauf
SourceApprovalRequest DataSource = "approval_request" // 4-eye inbox
)
// ViewRow is the union shape served by the substrate. The shape is
// projection-stable: every source fills the common header fields; type-
// specific fields hang off `Detail` as a discriminated payload.
type ViewRow struct {
Kind DataSource `json:"kind"` // discriminator
ID uuid.UUID `json:"id"` // source-row id
Title string `json:"title"` // display title
Subtitle *string `json:"subtitle,omitempty"` // short context line
EventDate time.Time `json:"event_date"` // canonical sort key
// Project context — every row in paliad has a project (approval_requests
// and project_events are project-attached by definition; deadlines and
// appointments may be personal but inherit project context when set).
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectReference *string `json:"project_reference,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
// Actor — who created this row (deadline/appointment) or who acted
// on it (project_event author, approval_request requester).
ActorID *uuid.UUID `json:"actor_id,omitempty"`
ActorName *string `json:"actor_name,omitempty"`
// Detail carries the source-specific payload the render layer reads
// when it needs more than the header (e.g. cards render the deadline
// status pill, the calendar renders the appointment time range, the
// activity feed renders the audit description).
Detail json.RawMessage `json:"detail"` // shape determined by `kind`
}
```
`Detail` is a per-source typed Go struct (`DeadlineDetail`, `AppointmentDetail`, `ProjectEventDetail`, `ApprovalRequestDetail`) marshalled via `json.RawMessage` so the row stays a single struct on the wire. The frontend type-narrows on `kind`.
Why a registry over a single virtual SQL view:
- The four source tables have **truly disjoint columns** — deadline has `due_date` and `rule_code`, appointment has `start_at`/`end_at`/`location`, project_event has `event_type` (free text) + `metadata jsonb`, approval_request has `lifecycle_event` + `requested_at`. A `UNION ALL` materialised view ends up with ~40 nullable columns, half of them per row.
- Per-source filtering is fundamentally different — deadline filters look at `status`, appointment filters look at `appointment_type`, project_event filters look at `event_type`, approval_request filters look at `lifecycle_event` + `status`. Translating those into one CHECK-style filter grammar is harder than running per-source SQL paths and merging.
- The substrate already exists in miniature today — `event_service.go` line 114 union-loads two sources and merges in Go. Generalising to four sources is the same shape, more code, no new architectural concept.
### Q2 — Filter grammar shape
**Recommendation: structured JSON spec, validated server-side, exposed to the UI as predicates.**
```json
{
"version": 1,
"sources": ["deadline", "appointment", "project_event", "approval_request"],
"scope": {
"projects": "all_visible",
"personal_only": false
},
"time": {
"horizon": "next_30d",
"field": "auto"
},
"predicates": {
"deadline": {
"status": ["pending"],
"approval_status": ["approved", "pending", "legacy"],
"event_types": [],
"include_untyped": true
},
"appointment": {
"approval_status": ["approved", "pending", "legacy"],
"appointment_types": []
},
"project_event": {
"event_types": [
"project_created", "status_changed", "project_archived",
"deadline_created", "appointment_created", "approval_decided"
]
},
"approval_request": {
"viewer_role": "approver_eligible",
"status": ["pending"],
"entity_types": ["deadline", "appointment"]
}
}
}
```
The shape:
- **`sources`** — one or more `DataSource` values. Drives which per-source SQL paths run.
- **`scope.projects`** — `"all_visible"` (default — RLS-bounded) | `"my_subtree"` (semantic: caller's direct/derived staffing tree) | `[<uuid>...]` (explicit list, RLS still applies).
- **`scope.personal_only`** — narrows deadline + appointment to caller-created rows; ignored for project_event + approval_request (where actor scoping is already implicit).
- **`time.horizon`** — `"any"` | `"next_7d"` | `"next_30d"` | `"next_90d"` | `"past_30d"` | `"past_90d"` | `"all"` | `{from, to}` literal range. `"auto"` for the date field means each source picks: deadline → `due_date`, appointment → `start_at`, project_event → `created_at`, approval_request → `requested_at` (or `decided_at` if status is decided).
- **`predicates.<source>`** — per-source narrowing (status, types, eligibility). Empty / missing = no narrowing.
Validation lives in Go: a `ValidateFilterSpec(spec)` function rejects unknown fields, unknown enum values, conflicting combos (`personal_only=true` + explicit `projects` list → error). The UI never sends raw user-typed JSON; it composes the spec from widget state. A "Show JSON" reveal is available in the editor for power users — but the same validator runs on POST.
Three options considered:
| Option | Power | Risk | Verdict |
|---|---|---|---|
| **JSON predicate spec (recommended)** | High — every dimension addressable | Schema drift → validator bug | ✅ |
| SQL-fragment DSL (`WHERE status='pending' AND …`) | Highest | Injection, RLS-bypass risk; needs a parser | ✗ |
| UI-only, no spec language | Lowest | Every new dimension = UI work + DB migration | ✗ |
### Q3 — Granularity dimensions
m's brainstorm called out: my projects / specific projects / newly added cases / newly added events / changes to events / approved-vs-unapproved / time horizon / event type / role-perspective.
The full dimension set, mapped to the spec:
| Dimension | Where it lives in `FilterSpec` | UI affordance | Notes |
|---|---|---|---|
| My projects | `scope.projects = "my_subtree"` | toggle | semantic, resolved at query time via t-139 derivation predicate |
| Specific projects | `scope.projects = [...]` | multi-select | RLS still applies; rows from inaccessible projects are silently filtered (Q17) |
| Personal-only | `scope.personal_only = true` | toggle | mutually exclusive with `projects` (server enforces) |
| Newly added cases | `sources: ["project_event"]` + `predicates.project_event.event_types: ["project_created"]` + `time.horizon` | source toggle + event-type chip group | same shape captures status_changed, project_archived |
| Newly added events | `sources: ["deadline","appointment"]` + `time.horizon` + `time.field = "created_at"` | source toggles + time-field selector | the `created_at` rather than `due_date`/`start_at` view |
| Changes to events | `sources: ["project_event"]` + `predicates.project_event.event_types: ["deadline_*","appointment_*"]` | event-type chips | project_events already audit deadline + appointment lifecycle (verified via existing emit sites) |
| Approval status of entities | `predicates.deadline.approval_status` + `predicates.appointment.approval_status` | tri-state chip | reflects the entity-side `approval_status` column |
| Approval lifecycle (the requests themselves) | `sources: ["approval_request"]` + `predicates.approval_request.status` + `predicates.approval_request.viewer_role` | source toggle + role chip | Q13 — the inbox shape |
| Time horizon | `time.horizon` + optional `{from, to}` | range chips + date pickers | shared across all sources |
| Event type (deadline) | `predicates.deadline.event_types` | multi-select | reuses existing `paliad.event_types` registry |
| Appointment type | `predicates.appointment.appointment_types` | multi-select | hearing/meeting/consultation/deadline_hearing |
| Project event kind | `predicates.project_event.event_types` | multi-select | free-text today; we'll need a curated list (§10 Q19) |
| Role-perspective | implicit — every query is "from caller's viewpoint" | n/a | not a filter; visibility predicate is the user identity |
Hidden defaults vs UI affordances:
- **Hidden** — `version`, `time.field` (`"auto"` is the default), per-source `include_untyped`, validator branches.
- **First-class UI** — sources, scope, time horizon, status, event_type/appointment_type/project_event_kind, approval status.
- **Power-only** (revealed in JSON editor) — explicit `{from, to}` ranges beyond the chip set, `time.field` override.
### Q13 — Approval candidates: predicate or source?
**Recommendation: source (`approval_request`).**
Reasoning: the approval_requests table has fundamentally different columns (`lifecycle_event`, `pre_image`, `payload`, `requested_by`, `decision_kind`, `decided_at`) than deadline/appointment, and the inbox UI renders different things (requester avatar, "Approve / Reject" buttons, decision history). Forcing this into a predicate on deadline/appointment rows means either:
- (a) hiding the request rows entirely — but then "show me pending approvals" is impossible to express, or
- (b) hydrating every deadline row with its pending-request payload — bloats the row shape, kills the "approval_status pill" abstraction.
By making it a source:
- `sources: ["approval_request"]` is the *inbox shape* — list of pending requests, decided requests, etc.
- `predicates.deadline.approval_status: ["pending"]` is the *entity shape* — list of deadlines that have a pending request (good for "show me my deadlines that are blocked on someone else's approval").
These are genuinely two views; the substrate exposes both.
---
## 4. Section B — Render shapes + view authoring UX (Q4Q6, Q11Q12, Q16)
### Q4 — Which render shapes are first-class for v1?
**Recommendation: `list`, `cards`, `calendar` — three shapes.**
m's correction (2026-05-07): activity is a content selection (sources + filters), not a render shape. The "compact one-line stream with type icons" appearance is `list` shape with `density: "compact"` + an actor/time column set — same component, different config. Shape is orthogonal to source: any source can render in any shape.
| Shape | Status today | What it does | Source bias |
|---|---|---|---|
| **`list`** | shipped on `/events` (table), `/inbox` (`<ul class="inbox-list">`), `/dashboard` activity feed | One row per result; columns vary per source. Table for desktop, stacked card-rows on mobile. Density modes: `comfortable` (default, full table) / `compact` (one-line stream — the activity-feed look). | source-agnostic |
| **`cards`** | shipped on `/agenda` (day-grouped timeline) | Day-grouped chronological cards; primary date drives grouping. The unified-inbox-feel m described — *when fed activity-style content*. | source-agnostic |
| **`calendar`** | shipped on `/events?view=calendar` | Month grid (toggleable to week). Shows up to N pills per day. Click → popup with the day's rows. | works best for time-bound sources (deadline, appointment, project_event) |
How "activity feed" is expressed in this model:
- **Filter side**: `sources: ["project_event", "approval_request"]`, `time.horizon: past_30d`, `time.field: created_at`.
- **Render side**: `shape: "list"`, `list.density: "compact"`, `list.columns: ["time", "actor", "title", "project"]`.
That same `list` shape — with `density: "comfortable"` + the deadline column set — also powers `/events`. One component, two configs. Same logic for `cards`: the day-grouped Verlauf on `/projects/{id}` and a "newest cases this week" card view share the component.
Defer to v2: `kanban` (no obvious column axis across mixed sources), `connections-graph` (the events↔files visualisation referenced in the issue body — that's specifically about graph rendering, which is a 5x bigger component and works better as its own page than as a saved-view shape), `timeline-distinct-from-cards` (a horizontal Gantt would be the natural shape but adds a lot for marginal value at v1).
Why these three and not all six: each shape is a real frontend component with empty states, error states, layout, density toggles, mobile behaviour. We have three already shipped today, generalising them costs little. Adding `kanban` + `graph` is each its own component-week. Better to ship 3 polished than 6 half-baked.
### Q5 — Per-shape config
**Recommendation: shape config lives alongside filter spec in `render_spec`, keyed by shape.**
```json
{
"shape": "list",
"list": { "columns": ["date", "title", "project", "status"], "sort": "date_asc", "density": "comfortable" },
"cards": { "group_by": "day", "sort": "date_asc", "show_empty_days": false },
"calendar": { "default_view": "month", "show_weekends": true }
}
```
The user picks one `shape`; the matching config block is read at render time. Other shape configs are kept (so flipping back to a previously-used shape preserves its tweaks).
UI: the shape switcher is a **3-button row** at the top of every view page. Right of it, a small "Shape settings" gear opens a modal with the per-shape knobs. Most users never touch the gear.
Default values per shape:
- `list.columns` = source-determined (deadline view = date/title/rule/status; appointment view = date/title/location/type; activity-feel view = time/actor/title — auto-selected when sources are activity-flavoured)
- `list.density` = `"comfortable"` for entity sources, `"compact"` when sources include project_event or approval_request
- `list.sort` = `"date_asc"` for forward-looking views, `"date_desc"` for retrospective
- `cards.group_by` = `"day"`
- `calendar.default_view` = `"month"`
### Q6 — Empty state per view
**Recommendation: filter-aware empty states. Render component receives the resolved `FilterSpec` and produces a guidance line.**
Generic shape:
> **Keine Einträge gefunden.**
> Sicht: *{view name}* — {N} Filter aktiv (*Zeitraum: nächste 7 Tage, Status: offen*).
> Vorschläge: [Zeitraum erweitern] [Filter zurücksetzen]
The component derives the human-readable filter summary from the spec. For specific known patterns:
- All-empty across sources + horizon `next_7d` → "Nichts in den nächsten 7 Tagen — versuchen Sie 30 Tage."
- Sources picked but all 0 in 90d → "Keine Daten für diese Quellen — Sicht eventuell zu eng."
- Project filter set but project has no team → already handled at API layer (Q17).
Empty-state strings live in i18n; the view name + filter summary are interpolated at render time.
### Q11 — Where do you create a view?
**Recommendation: both, with the inline path as primary.**
Two creation paths:
1. **Inline "save current filters as a Sicht"** (primary) — on any view page (system or existing custom), once the user has tweaked the filter spec away from the saved baseline, a "Speichern als Sicht" button appears in the toolbar. Click → modal asks for name + icon + sidebar position + render shape (defaults to current). Save → POST `/api/user-views` → sidebar refreshes → user is now on the new view. The same modal on an existing custom view shows a "Save changes / Save as new" pair.
2. **Full editor at `/views/new`** (secondary) — for the power case where the user wants to build a Sicht from a blank slate. Same modal fields, plus a JSON view of the filter spec for power users. Edit existing at `/views/{slug}/edit`.
Why both:
- The inline path covers the 90% case ("I tweaked the inbox to show only my projects, save it") with one click.
- The full editor covers the 10% case where the user knows what they want but isn't currently looking at the right starting point ("I want a view of all approval-decided rows in the last 90 days").
Critically, **the inline path teaches the full editor** — both render the same form component.
### Q12 — Default-first onboarding
**Recommendation: empty + tutorial card on the first visit. No seeded examples.**
When a user with zero saved views clicks "Meine Sichten" or visits `/views`, they see:
> **Eigene Sichten — was ist das?**
> Eine Sicht ist eine gespeicherte Filterkombination — z.B. "Fristen meiner Projekte in den nächsten 14 Tagen". Sichten erscheinen als eigene Buttons in der Sidebar.
> [Beispiel-Sicht erstellen ▶] [Aus aktueller Seite speichern ▶]
The first button drops the user into the editor pre-populated with a sensible starter (e.g. "Activity feed for my subtree, last 30 days"). The second is contextual — only appears if the user has been on a system page recently (tracked client-side).
Why no seeded rows: seeded examples become orphan-confusion later ("did I make this Freitag-Stand thing? when?"). A dismissible tutorial card is cheaper to maintain and clearer about ownership.
### Q16 — URL contract
**Recommendation: `/views/{slug}` for custom views, slug user-scoped. System views keep their existing URLs.**
- **`/views/{slug}`** — slug is unique per `(user_id, slug)`. Slug is friendly: `freitag-stand`, `approvals-pending-mine`, `siemens-aktivitaet`. No UUIDs in URLs.
- **`/views/new`** — creation editor.
- **`/views/{slug}/edit`** — edit existing.
Filter overrides via query params:
- `/views/freitag-stand?from=2026-05-10&to=2026-05-17` — overrides the saved time horizon for this load only. Doesn't mutate the stored spec.
- `/views/freitag-stand?shape=calendar` — overrides the saved render shape for this load only.
Override params follow the same validator as the stored spec; unknown params are ignored.
System views — `/dashboard`, `/agenda`, `/events`, `/inbox` — keep their URLs. They never become `/views/dashboard` (a slug collision the validator must reject — slug `dashboard` is reserved).
---
## 5. Section C — Persistence + sidebar + system-vs-user-view shape (Q7Q10, Q14, Q15, Q17, Q18)
### Q7 — Schema for `paliad.user_views`
**Recommendation:**
```sql
CREATE TABLE paliad.user_views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
-- Stable user-facing identifier. Goes into the URL. Validated:
-- ^[a-z0-9][a-z0-9-]{0,62}$ with reserved-list rejection (dashboard,
-- agenda, events, inbox, new, edit, …).
slug text NOT NULL,
-- Display name. Free-form; no enforced i18n (the user picks the language
-- they think in). Sidebar renders it verbatim; no fallback or translation.
name text NOT NULL,
-- One of a fixed set of icon keys (see frontend/src/components/Sidebar.tsx
-- icon registry). NULL → default icon (folder).
icon text,
-- Filter spec (§3 Q2). Validated on write.
filter_spec jsonb NOT NULL,
-- Render spec (§4 Q5). Validated on write.
render_spec jsonb NOT NULL,
-- Sidebar ordering. Lower-first. Server defaults to MAX+1 on insert so
-- new views land at the bottom; the editor lets the user drag-reorder.
sort_order int NOT NULL DEFAULT 0,
-- Show a row-count badge on the sidebar entry (like /inbox today).
-- Costs one COUNT(*) per saved view per badge refresh; opt-in.
show_count boolean NOT NULL DEFAULT false,
-- "Most-recently-used" landing (Q10). PATCH on every view-load (cheap).
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, slug)
);
CREATE INDEX user_views_owner_idx
ON paliad.user_views (user_id, sort_order);
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_views_owner_all
ON paliad.user_views FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- updated_at autoset trigger reusing existing paliad.set_updated_at().
CREATE TRIGGER user_views_updated_at
BEFORE UPDATE ON paliad.user_views
FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();
```
Notes on the shape:
- **No `is_system` flag** — system views are code-resident (Q8), not seeded rows. Keeps the table strictly user-owned.
- **`filter_spec`/`render_spec` as `jsonb`** — Postgres validates only structural well-formedness; the application layer (`ValidateFilterSpec` + `ValidateRenderSpec`) enforces semantic constraints at write time. Storing the parsed shapes as columns would force a schema migration per new dimension.
- **No cross-user sharing column** — explicit `OUT OF SCOPE` per the issue body. If sharing lands later, add a separate `user_view_shares (view_id, target_user_id, can_edit)` table.
- **Slug uniqueness scoped to user** — two users can both have a view called `freitag-stand`; URL is `/views/freitag-stand` and resolves against `auth.uid()`.
Migration shape: new file `056_user_views.up.sql`. Standalone — no dependencies on 055's schema beyond `paliad.users` (which is in 002). 056 can land before 055 lands on main if needed.
### Q8 — System views: code or DB?
**Recommendation: code-resident.** Defaults stay as their own pages; their handlers continue to render their existing TSX shells; their data path is the substrate.
```go
// internal/services/system_views.go (new)
// SystemView is a code-resident view definition. Used by the substrate
// when a system page (dashboard, agenda, events, inbox) needs to resolve
// its data through the unified pipeline.
type SystemView struct {
Slug string // "dashboard" | "agenda" | "events" | "inbox" — matches URL
Filter FilterSpec // canonical spec the page resolves to today
Render RenderSpec // canonical render shape
Reserved bool // if true, slug is unavailable for user views (true for all 4)
}
func DashboardSystemView() SystemView { /* …multi-section, special-cased… */ }
func AgendaSystemView() SystemView { /* sources: deadline+appointment, shape: cards, horizon: 30d */ }
func EventsSystemView() SystemView { /* sources: deadline+appointment, shape: list, configurable */ }
func InboxSystemView() SystemView { /* sources: approval_request, viewer_role: approver_eligible, shape: list */ }
```
Tradeoff (config-as-code vs config-as-data):
| Axis | Code (recommended) | DB seed |
|---|---|---|
| Ships with releases | ✅ atomic with code | ✗ requires per-user backfill |
| New users get latest | ✅ always | ✗ depends on seed timing |
| User-editable | ✗ — system views deliberately frozen | ✅ — but then "system" is meaningless |
| Drift risk | none | high (schema bump → seeded rows go stale) |
| Validator complexity | one path | two paths (code path + seed path) |
The locked direction is "additive — fixed defaults stay alongside Custom Views". I read that as: defaults are *not* user-editable; the user can build a custom view that mimics a default if they want a tweaked version. Config-as-code matches that intent exactly.
Dashboard is the awkward one — it's not a single saved view, it's a multi-section page (5-bucket summary + matter card + 2-column lists + activity feed). The recommendation is: keep `/dashboard` as a bespoke page composed of *several* internal queries, each of which can resolve to a `SystemView` later. Don't try to express the dashboard as one SystemView; that's the wrong abstraction.
### Q9 — Sidebar layout
**Recommendation:** new "Meine Sichten" group between "Arbeit" and "Werkzeuge".
```
Übersicht:
Dashboard
Agenda
Inbox [3]
Team
Arbeit:
Projekte
Fristen
Termine
Meine Sichten: ← new group
Freitag-Stand [12]
Approval-Pending-Mine
Siemens-Aktivität
+ Neue Sicht ← always-last entry
Werkzeuge: …
Wissen: …
Ressourcen: …
Einstellungen: …
Admin: …
```
Layout decisions:
- **Position**: between Arbeit and Werkzeuge — close to the work flow, before the tools/knowledge sections. m's brainstorm placed it as "a separate button" but didn't pin top vs bottom; this position keeps it in the work-context band.
- **Group label**: "Meine Sichten" / "My Views" — i18n key `nav.group.user_views`.
- **Empty group**: if the user has zero saved views, the group still renders, with only the "+ Neue Sicht" entry inside. That makes the feature discoverable; the alternative (hide empty group) buries it.
- **Per-entry icon**: from a fixed registry of ~20 icons (folder, calendar, clock, bell, files, users, …) reused from the existing sidebar SVG set. Default = folder.
- **Per-entry badge**: shown when `show_count=true` on the saved view. Server returns the count via `/api/user-views?include_count=true`; the same client refresh interval as `/api/inbox/count` (~60s). Badge is the count of currently-matching rows — same shape as the inbox bell today.
- **Drag-reorder**: the editor lets users drag entries; click-to-edit on hover.
- **Mobile**: the bottom-nav shows fixed entries only (Übersicht items) — saved views are accessible via the burger drawer. Otherwise the bottom-nav fills up the moment a power user has 5 saved views.
### Q10 — Default landing
**Recommendation: most-recently-used.**
When the user clicks "Meine Sichten" (the group label, not a specific entry), they navigate to `/views`, which resolves to:
- If `last_used_at` is set on any view → 302 to that view's URL.
- If no view has `last_used_at` → render the onboarding card (Q12).
`last_used_at` is updated on every view-load via a fire-and-forget PATCH `/api/user-views/{id}/touch`. Cheap; no UI latency.
Alternative (always-default to first by sort_order) was considered — feels less helpful (the user sorted by what they want to see *most easily*, but might not be visiting *most often*). Most-recently-used reflects actual workflow.
### Q14 — `/inbox` page
**Recommendation: stays as a fixed sidebar entry. Internally refactored to use the substrate.**
Three paths considered:
| Path | Pros | Cons |
|---|---|---|
| Keep `/inbox` as today, no internal change | zero migration risk | duplicate read path; "subsume" goal not met |
| **Refactor `/inbox` to use the substrate (recommended)** | one read path; future enhancements lift everyone | small migration effort |
| Retire `/inbox`, ship as a Custom View | cleanest concept | breaks every email link; users with the URL bookmarked get 404 |
The recommendation refactors `/inbox` internally but keeps the URL + sidebar entry. Concretely:
- The two-tab UI ("Zur Genehmigung" / "Meine Anfragen") on `/inbox` becomes two `SystemView` definitions:
- `InboxApproverView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "approver_eligible", status: ["pending"]}`, `render.shape: "list"`.
- `InboxRequesterView`: `sources: ["approval_request"]`, `predicates.approval_request: {viewer_role: "self_requested"}`, `render.shape: "list"`.
- The `/inbox` handler resolves to one of these depending on the active tab; the data path goes through `ViewService.Run(ctx, userID, spec)`.
- The frontend keeps the existing two-tab UI; the per-row card markup also stays (the substrate's `list` shape with `kind="approval_request"` knows how to render approval rows including approve/reject buttons).
- The `nav.inbox` sidebar entry stays; the bell badge keeps reading from `ApprovalService.PendingCountForUser`.
This satisfies the "subsume the unified-inbox concept" goal: any user can build a Custom View that picks `approval_request` as one source plus `project_event` as another, and gets the unified-inbox feel m's brainstorm described — without losing the dedicated `/inbox` shortcut.
### Q15 — Existing fixed pages: reroute or stay independent?
**Recommendation: phased.** Phase A (this design's implementation) leaves system pages independent; Phase B (separate later task) refactors them to use the substrate.
| Phase | Scope | Risk | Locked direction fit |
|---|---|---|---|
| **A — substrate + Custom Views ship; defaults untouched** | new code: ViewService, FilterSpec, RenderSpec, view_service handlers, /views/* pages, paliad.user_views | low — additive | exactly matches m's "additive" framing |
| **B — refactor /agenda, /events, /dashboard, /inbox internals to use ViewService** | rip out parallel read paths; defaults become SystemView-resolved | medium — touches every default page | optional; ship when A is stable |
Why phase A is enough on its own to ship value: the user gets Custom Views, the unified-inbox-shape becomes available, every system page keeps working untouched. Phase B is a clean-up — eliminating duplicate read paths — and can wait until A's substrate is exercised.
If we tried to do A+B in one shot, the PR would be:
- 1× new substrate (~1500 LoC across services + handlers + frontend)
- 4× system page refactors (~800 LoC each = ~3200 LoC)
- = ~4700 LoC, 4 surfaces moving simultaneously
That's a 2-week change and a much higher rollback-cost. Phasing means A is shippable in ~1500 LoC and B can be tackled per-page later.
### Q17 — Auth + RLS + lost project access
**Recommendation: fail open with attribution.**
Behaviour:
- A saved view's `filter_spec.scope.projects` may include UUIDs the user no longer has team access to.
- The substrate query JOINs through `paliad.projects p` with the visibility predicate (`paliad.can_see_project(p.id)` per t-139). RLS naturally hides rows from inaccessible projects.
- The view loads. The user sees the rows they *can* see; the inaccessible ones are absent.
- A one-time toast surfaces: "1 Projekt in dieser Sicht ist nicht mehr sichtbar" (count derived server-side: requested-IDs minus visible-IDs).
- The toast offers a "Sicht bearbeiten" link → opens the editor with the inaccessible IDs prefilled in a "Entfernen?" section.
Alternatives considered:
| Alternative | Why rejected |
|---|---|
| Fail closed (whole view 403) | Too aggressive — a 50-project view shouldn't black out because 1 was archived. |
| Silently drop with no surface | Confuses the user; "why is my view empty today?" |
| Auto-prune on first load | Mutates stored data without consent. |
Failing open + attributing matches the "transparent honesty" principle from t-139 (derived membership annotated, not silent).
### Q18 — Materialisation & performance
**Recommendation: no materialisation v1. Cursor pagination + per-source row caps.**
Performance shape:
- **Substrate runs on every load.** Each source contributes one SQL path; merge happens in Go (small per-page result set). No precomputation.
- **Pagination** is cursor-based: `(event_date DESC, id DESC)` for retrospective views, `(event_date ASC, id ASC)` for forward-looking. Cursor = base64-encoded `{date, id}`. Default page size 100; cap 200.
- **Time horizon is mandatory.** Default is `next_30d` for forward-looking views, `past_30d` for retrospective. The validator rejects `time.horizon = "all"` *unless* `scope.projects` is set to a non-empty explicit list (capping the row pool).
- **Per-source LIMIT** inside each SQL path (default 500; configurable per-source). Caps the worst case where one source dominates the union.
What this looks like for the worst case the issue body raised — "all events from all my projects in the next 90 days, sorted by due_date":
- 50 projects × thousands of rows each = ~150k rows, theoretical. In practice, paliad data today has dozens-to-low-hundreds per project; even at 50 projects, the *date-bounded* result is in the hundreds-low-thousands range.
- Each per-source query has the visibility predicate (RLS is via `EXISTS` against `project_teams` + path-walk) — t-124 confirmed this scales with depth, not row count.
- Even at 5k merged rows, in-memory sort + 100-row paginated slice is a few ms.
We add materialisation only if telemetry says we need to. Concretely: a request-duration histogram on `/api/views/{slug}/run` with p99 alarm at 1s. If p99 climbs past 500ms, we add per-source materialised rollups (e.g. `mv_user_view_counts_daily`) and short-circuit summary cards through them.
The substrate's `count` endpoint (used by the sidebar badge for `show_count=true` views) is a lighter shape — it returns one integer per source. That can hit a lighter path (no JOINs to projects beyond the RLS predicate). If a user has 10 saved views with `show_count=true` × 60s refresh = 10 COUNT(*) queries per minute per logged-in user. That's the first scale wall and is the candidate for caching in Phase B.
---
## 6. Section D — Cross-cutting concerns
### 6.1 Coexistence with t-139 (hierarchy aggregation, in flight)
t-139 adds `paliad.project_partner_units` + `derive_grants_authority` + an extended `can_see_project()` predicate. The substrate uses `can_see_project()` (or equivalent positional helpers like `visibilityPredicate("p")` already does) — so derived membership transparently widens what shows up in saved views, just like it widens what shows up on `/agenda` today.
**No coordination commit required.** If t-139 lands first, this design's substrate inherits derivation for free. If this design lands first (unlikely given the merge order), the substrate works against the pre-139 visibility predicate; t-139's later landing widens results without code change here.
The `scope.projects = "my_subtree"` semantic resolves through `DerivationService.EffectiveProjectRole` (added by t-139 Phase 2). Until t-139 lands, "my_subtree" falls back to "direct + descendant" (via `projectDescendantPredicate` from t-124). The frontend chip label stays the same; only the resolved set widens.
### 6.2 Coexistence with t-138 (approvals, shipped)
t-138 added `paliad.approval_requests` + `entity.approval_status` + the inbox SQL. The substrate uses `approval_requests` as `data_source = "approval_request"` directly — same RLS, same JOIN against `paliad.users` for requester/decider names. The substrate's approval-side filter `predicates.approval_request.viewer_role = "approver_eligible"` resolves via `ApprovalService.ListPendingForApprover` (its existing SQL).
The entity-side pill (`approval_status='pending'`) on deadline/appointment rows in the substrate is unchanged — `EventListItem.ApprovalStatus` is already populated in `event_service.go`.
### 6.3 Existing `EventService` — extend or replace?
**Recommendation: extend.** Rename `EventService``ViewService` (or keep `EventService` as the type and add a `ListVisibleAsViewRows` method that returns `[]ViewRow` instead of `[]EventListItem`). The existing `ListVisibleForUser([]EventListItem, …)` callers (`/api/events`, `/api/events/summary`) keep working unchanged.
Two-source → four-source generalisation:
- Add `loadProjectEventRows(ctx, userID, spec)` → similar to `loadAppointments` shape, queries `paliad.project_events` JOIN `paliad.projects` with visibility predicate.
- Add `loadApprovalRequestRows(ctx, userID, spec)` → wraps `ApprovalService.ListPendingForApprover` / `ListSubmittedByUser` and projects to `ViewRow`.
- The merge step in `ListVisibleForUser` becomes "merge N source results sorted by event_date".
`AgendaService` is the second substrate today (timeline-shaped). Phase B can retire it (Agenda becomes a SystemView with `shape: "cards"`); Phase A leaves it untouched.
### 6.4 i18n
User-facing strings:
- "Meine Sichten" / "My Views" (sidebar group label)
- "Neue Sicht" / "New View" (creation entry)
- "Speichern als Sicht" / "Save as View"
- "Sicht bearbeiten" / "Edit View"
- shape labels: "Liste / List", "Karten / Cards", "Kalender / Calendar"
- per-source labels: "Fristen / Deadlines", "Termine / Appointments", "Projekt-Verlauf / Project history", "Genehmigungen / Approvals"
- empty-state composition strings (filter summary)
- error toast for inaccessible-project case
Total estimate: ~80 new keys, DE + EN.
### 6.5 Bottom nav (mobile)
The bottom nav today shows 4 fixed entries (Übersicht-band). It does NOT extend with saved views — that would balloon to N+4 at every saved view. Saved views remain accessible via the sidebar drawer.
If telemetry shows mobile users routinely hitting saved views, consider a "Pin to bottom-nav" toggle on individual views (max 1 pinned view added between Übersicht and the burger).
---
## 7. Section E — Implementation phasing (PR shape)
### PR split decision (2026-05-07)
m delegated the split call to the inventor. Phase A is split into **two stacked PRs**:
- **A1 — Backend substrate + Custom Views API.** Migration 056, FilterSpec/RenderSpec types + validators, ViewService 4-source extension, UserViewService CRUD, SystemView registry, all `/api/*` endpoints, full backend test coverage. *No user-visible change.* Smoke-testable via curl. ~1800 LoC.
- **A2 — Frontend Custom Views UI.** Generic view shell (`/views/{slug}`), view editor (`/views/new`, `/views/{slug}/edit`), 3 render-shape components (list/cards/calendar), sidebar "Meine Sichten" group, i18n, CSS. Builds on A1's API. ~1600 LoC.
Why split: A1 is mergeable + deployable in isolation (additive, no UI risk), exercises the validator surface, lets A2 build on a stable contract. A2 is purely additive once A1 lands. Each PR fits in a normal review window.
A1 → main → A2 → main is the merge order.
### Phase A — substrate + Custom Views (this task's locked scope)
| Step | Files | Approx. LoC | Notes |
|---|---|---|---|
| 1. Migration `056_user_views` | `internal/db/migrations/056_user_views.up.sql` (+ down) | 60 | table + indexes + RLS + trigger |
| 2. Filter/Render spec types + validator | `internal/services/filter_spec.go`, `render_spec.go` | 350 | Go structs + JSON marshalling + `Validate*` |
| 3. ViewService — extend EventService | `internal/services/view_service.go` (rename + extend) | 500 | add 2 source loaders; merge N sources |
| 4. UserViewService — CRUD | `internal/services/user_view_service.go` | 300 | List/Get/Create/Update/Delete/Touch |
| 5. SystemView registry | `internal/services/system_views.go` | 150 | 4 SystemView definitions + reserved-slug list |
| 6. HTTP handlers | `internal/handlers/views.go` (new) + adjust `events.go`, `agenda.go`, `inbox.go` minimally | 400 | `/api/user-views/*`, `/api/views/{slug}/run`, `/views/*` page handlers |
| 7. Frontend — generic view shell | `frontend/src/views.tsx` + `client/views.ts` | 500 | renders any FilterSpec + RenderSpec; powers `/views/*` |
| 8. Frontend — render shape components | `frontend/src/views/{list,cards,calendar,activity}.ts` | 600 | shared by system + custom |
| 9. Frontend — view editor | `frontend/src/views-editor.tsx` + client | 400 | inline-save modal + full editor |
| 10. Sidebar — Meine Sichten group | `frontend/src/components/Sidebar.tsx` + sidebar.ts | 150 | render saved views from /api/user-views; badge refresh |
| 11. i18n | `frontend/src/i18n.ts` | ~80 keys | DE + EN |
| 12. Tests | `*_test.go` for spec validators + ViewService | 400 | spec round-trip, RLS, source merge ordering |
| **Total** | | ~3400 | one PR |
Phase A ships standalone — no defaults are touched, no existing pages move.
### Phase B — refactor system pages onto substrate (separate task)
Per-page refactor: `/agenda` (substrate-shape `cards`), `/events` (substrate-shape `list`/`calendar`), `/inbox` (substrate-shape `list` + tab tied to viewer_role), `/dashboard` (composes multiple SystemViews into its sections). Each is its own PR. Total estimate: ~2000 LoC across all four. Ships any time after A is stable.
### Phase C — sharing + advanced shapes (future)
Cross-user sharing (`user_view_shares`), connections-graph render shape, kanban shape, real-time push updates. None of these are in scope for the current task; called out so the v1 spec doesn't paint us into a corner.
---
## 8. Section F — Worked examples
### 8.1 The unified-inbox m described
m's brainstorm: "approval candidates + project activity + new cases + status changes + everything that happened on my projects."
`FilterSpec`:
```json
{
"version": 1,
"sources": ["approval_request", "project_event", "deadline", "appointment"],
"scope": { "projects": "my_subtree" },
"time": { "horizon": "past_30d", "field": "auto" },
"predicates": {
"approval_request": { "viewer_role": "approver_eligible", "status": ["pending"] },
"project_event": { "event_types": ["project_created", "status_changed", "deadline_created", "appointment_created", "approval_decided", "project_archived"] },
"deadline": { "approval_status": ["approved","pending","legacy"], "status": ["pending"] },
"appointment": { }
}
}
```
`RenderSpec`:
```json
{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title", "project"], "sort": "date_desc" } }
```
(The "activity-feed feel" comes from `density: "compact"` + the actor/time column set, not from a separate shape — m's correction 2026-05-07.)
User saves as `meine-aktivitaet`. URL: `/views/meine-aktivitaet`. Sidebar entry under "Meine Sichten" with the bell icon. show_count=true → badge shows count of pending approvals + new audit events in past 30d.
### 8.2 The "myVerySpecialAgenda"
```json
{
"version": 1,
"sources": ["deadline", "appointment"],
"scope": { "projects": [<project-uuid-1>, <project-uuid-2>] },
"time": { "horizon": "next_14d" },
"predicates": {
"deadline": { "status": ["pending"], "event_types": [<litigation-event-type-uuid>] },
"appointment": { "appointment_types": ["hearing", "deadline_hearing"] }
}
}
```
`RenderSpec`: `{ "shape": "calendar", "calendar": { "default_view": "week" } }`
### 8.3 "Was hat sich auf Siemens AG geändert?"
```json
{
"version": 1,
"sources": ["project_event"],
"scope": { "projects": [<siemens-client-uuid>] },
"time": { "horizon": "past_90d" },
"predicates": { "project_event": { "event_types": ["status_changed", "project_reparented", "deadline_completed"] } }
}
```
`RenderSpec`: `{ "shape": "list", "list": { "density": "compact", "columns": ["time", "actor", "title"], "sort": "date_desc" } }`
(`scope.projects` referencing a top-level Client UUID + the path-walk visibility predicate naturally pulls all descendants — this is exactly the t-139 aggregation, surfaced through the substrate.)
---
## 9. Section G — Trade-offs flagged
1. **Substrate complexity vs default-page simplicity.** The substrate is meaningfully more complex than today's `EventService`. The win is that *every future "show me X across my work"* request maps to the same code path. Without it, every new viewpoint is a new bespoke handler — t-138's inbox is the most recent precedent (~900 LoC).
2. **JSON spec discoverability.** Power users will appreciate the JSON-spec affordance; casual users may never see it. The risk is that the affordance attracts feature-creep ("can we just add a `like_pattern` predicate?"). Mitigation: `version: 1` field + strict validator + a "spec changes go through inventor" rule documented in `docs/`.
3. **Storage cost of `paliad.user_views`.** Each saved view is ~2KB jsonb. 100 active users × 5 saved views = 1MB. Negligible.
4. **Sidebar growth.** Heavy users may end up with 10+ saved views in the sidebar group. The drag-reorder editor is the relief valve; if pain emerges, add a "Collapse group" affordance.
5. **`show_count` query load.** Each show_count=true view = 1 COUNT(*) per refresh. If users go count-happy, this becomes a real load. Mitigation: cap show_count=true to 5 per user; cache counts for 30s server-side.
6. **System pages staying independent (Phase A).** Two read paths during the A→B window. Drift risk if the substrate gains behaviour the system pages miss. Mitigation: feature flag the new `/views/*` for power users until B is in flight.
7. **Slug collisions with future system URLs.** Reserve a static list (`dashboard`, `agenda`, `events`, `inbox`, `new`, `edit`, `tools`, `admin`, `settings`, `login`, `logout`, `projects`, `team`, `courts`, `glossary`, `links`, `downloads`, `checklists`, `views`). Validator rejects on write. Future URLs added → migration script renames any user views that crash.
8. **Mobile UX of in-page render-shape switcher.** Calendar shape on a phone is cramped. Mitigation: when viewport width < 600px, calendar shape auto-falls back to cards (with a notice). Same pattern as `/events` today.
---
## 10. Section H — Open questions for m
**Status: LOCKED 2026-05-07.** m signed off on all Q19Q27 recommendations.
Inventor has made recommendations on every Q1Q18 from the issue body. The questions below are points where m's call would specifically refine the design before coder shift starts. Numbered fresh (Q19+) so they don't collide with the issue body's numbering.
**Q19. Curated `project_event` event-type list.**
The audit table today has free-text `event_type` strings (`project_created`, `status_changed`, `deadline_created`, `approval_decided`, …). The substrate's filter dropdown needs a curated list. Should I:
- (a) ship a hardcoded list of ~12 known kinds (verified via grep on `insertProjectEvent` callsites), or
- (b) ship a `paliad.project_event_kinds` registry table seeded with the same list, future-extensible by admins?
Recommendation: (a). Free-text `event_type` is a code-resident constant; new kinds appear when code emits them, so a registry table would just shadow the code.
**Q20. Sidebar group position.**
I placed "Meine Sichten" between Arbeit and Werkzeuge. Three other reasonable positions:
- top of the sidebar (above Übersicht most-used-first)
- inside Übersicht (mixed with Dashboard/Agenda but blurs the system/user distinction)
- between Übersicht and Arbeit (saved views are *overviews* by intent)
Pick one the implementation is identical in all four placements.
**Q21. Bottom-nav inclusion.**
Mobile bottom-nav today has 4 fixed entries. The recommendation is to **not** extend it with saved views (sidebar drawer fills the gap). Confirm or reject. If reject: should pinned views be a per-view setting (max 1 pinned), or auto-pin the most-recently-used?
**Q22. Show-count default.**
Per-view `show_count` defaults to false (recommendation §5 Q7). Confirm alternative is default true with an explicit opt-out. The cost of true-default is more COUNT(*) queries.
**Q23. Reserved slugs.**
List of forbidden user-view slugs 9 trade-off 7). Anything to add or remove?
**Q24. Phase A surface area in coder shift.**
Phase A is ~3400 LoC. Confirm one PR is the right shape, or split into A1 (substrate + spec types + system view refactor of /events only) + A2 (Custom Views CRUD + sidebar + editor)?
**Q25. View deletion confirmation.**
A user deleting a saved view: should I require a "type the view name to confirm" pattern (matching admin deletes elsewhere in paliad), or a single Yes/No modal?
**Q26. Time-horizon mandatory clamp.**
The validator rejects `time.horizon = "all"` unless `scope.projects` is non-empty (perf safeguard, §5 Q18). Does this feel right, or should `"all"` always be allowed (and we trust the per-source LIMIT to bound things)?
**Q27. Render-spec live preview in editor.**
The editor today (proposed) saves on submit. Should the editor render a *live preview* of the current spec (running the substrate against the in-progress filter) useful but adds a query per keystroke? Default-debounced (500ms) or explicit "Vorschau" button?
---
## 11. Out of scope (v1)
Per the issue body quoted for traceability:
- Replacing the fixed pages (they stay; can be removed later if usage warrants).
- Cross-user view sharing.
- Public / read-only links to views.
- Real-time push updates ("inbox row appears when someone files an approval").
- Cross-project rollups (rolling rows across unrelated projects).
- Themes / per-view colour palettes.
Adding from inventor analysis:
- Connections-graph render shape (deferred per §4 Q4 its own page later).
- Kanban shape (no obvious column axis across mixed sources).
- "Pin to bottom-nav" mobile affordance.
- Materialised view/cache layer (deferred per §5 Q18 telemetry-driven).
---
## 12. Files the implementer will touch (Phase A)
Backend:
- `internal/db/migrations/056_user_views.up.sql` + `.down.sql` (new)
- `internal/services/filter_spec.go` (new) types + validator
- `internal/services/render_spec.go` (new) types + validator
- `internal/services/view_service.go` (new extends/renames `event_service.go`)
- `internal/services/user_view_service.go` (new) CRUD
- `internal/services/system_views.go` (new) 4 SystemView definitions
- `internal/services/event_service.go` update callers (or alias for back-compat)
- `internal/handlers/views.go` (new) `/api/user-views/*`, `/api/views/{slug}/run`, page handlers for `/views/*`
- `internal/handlers/handlers.go` wire the new routes
- `internal/handlers/inbox.go` (light touch) refactor read path to `ViewService` (Phase B candidate; can stay independent in Phase A if we want to minimize blast radius)
Frontend:
- `frontend/src/views.tsx` (new) generic view shell (`/views/{slug}` and `/views`)
- `frontend/src/views-editor.tsx` (new) full editor at `/views/new`, `/views/{slug}/edit`
- `frontend/src/client/views/list.ts`, `cards.ts`, `calendar.ts`, `activity.ts` (new) render shape components
- `frontend/src/client/views.ts` (new) view shell glue + shape switcher
- `frontend/src/client/views-editor.ts` (new) editor logic
- `frontend/src/components/Sidebar.tsx` add Meine Sichten group + render from `window.__PALIAD_USER_VIEWS__`
- `frontend/src/client/sidebar.ts` fetch/cache user views; badge refresh
- `frontend/src/i18n.ts` ~80 new keys DE+EN
- `frontend/src/styles/global.css` view-shell + render-shape switcher styles
Tests:
- `internal/services/filter_spec_test.go` validator (happy + edge cases + reject paths)
- `internal/services/render_spec_test.go` same
- `internal/services/view_service_test.go` 4-source merge ordering, RLS bounded
- `internal/services/user_view_service_test.go` CRUD + RLS
- `frontend/src/client/views/*.test.ts` (if frontend testing infra exists; otherwise smoke via Playwright)
Build infra: none uses existing `golang-migrate` + Bun pipelines.
---
## 13. Inventor stays parked
This design needs m's go on §10 (Q19Q27) before coder shift starts. After m's call, the head routes the implementer (recommendation: noether or fresh coder; Phase A is mechanical-substantial but pattern-fluent t-139's hierarchy substrate is the closest precedent in the codebase).
NOT cronus per m's directive (2026-05-06: cronus retired from paliad).
`mai report completed "DESIGN READY FOR REVIEW: data display model — additive Custom Views + 4-source substrate + 4 render shapes + paliad.user_views. 27 questions answered (18 from issue body + 9 follow-ups in §10). Awaiting m's go/no-go before coder shift."`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,947 @@
# Design: Local Chat for Teams (t-paliad-145)
**Status:** READY FOR REVIEW
**Author:** noether (inventor)
**Issue:** [m/paliad#8](https://mgit.msbls.de/m/paliad/issues/8)
**Date:** 2026-05-07
**Branch:** `mai/noether/inventor-local-chat-for`
---
## §0 TL;DR
A new chat surface inside paliad: **per-project threads + 1:1/small-group DMs**, with @mentions and entity references (`#frist-…`, `#projekt-…`, `#approval-…`). Real-time delivery via **SSE** (one new long-lived endpoint). New schema: `paliad.chat_threads` + `paliad.chat_messages` + `paliad.chat_reads` + `paliad.chat_thread_participants` + `paliad.chat_mentions`. Visibility composes the existing `paliad.can_see_project` predicate; write-access adds a `chat_access` flag on `project_teams` (default ON for internal roles, OFF for `local_counsel`/`expert`); `observer` is read-only.
In-app badge in the sidebar (alongside the existing approvals bell). **No PWA push, no email digest, no attachments, no search-across-threads in v1** — all deferred to Phase 2. Markdown subset (bold, italic, code, lists, links, blockquote — no headings, no images). Edit window 5 min by author; soft-delete by author or admin. System auto-post into project chat when an approval is requested on that project (the only auto-event in v1).
Total scope: one migration (`057_chat`), one new service (`ChatService`) + an in-process pubsub (`ChatBus` interface — pg_notify implementation later when paliad multi-replicas), eight HTTP endpoints, one new top-level page `/chat`, one new `/projects/{id}` tab. Estimated ~35004500 LoC for the bundled v1 ship; phasable into 3 PRs (schema + service core, real-time + frontend, mentions + auto-post).
**Trade-off flagged up-front (read §9.1 before approving):** chat-in-paliad collides with HLC's existing internal comms (Slack/Teams/WhatsApp). Compliance is the cited differentiator, but adoption depends on whether team members actually move "Anna, kannst du auf meine Frist 16.05. drauf schauen?" from WhatsApp into paliad. Recommend m sanity-check this with two PA colleagues before locking the v1 scope.
---
## §1 Premises verified live (2026-05-07)
Before designing on top, I verified each load-bearing claim against the running system rather than CLAUDE.md / memory:
| Claim | Source | Verification |
|---|---|---|
| `paliad.notifications` does not exist | issue body Q5/§References | `information_schema.tables WHERE table_schema='paliad'` — confirmed absent. Only `paliad.reminder_log` (email dedup). |
| Service worker is cache-only (no push handler) | brand expectation | `frontend/public/sw.js` — only `install`/`activate`/`fetch` handlers. No `push`, no VAPID keys, no `web-push` Go dep. |
| Supabase realtime is not enabled on this Postgres | infra | `pg_extension WHERE extname='supabase_realtime'` → empty. Adding it is a separate decision (changes paliad's Postgres surface). |
| `paliad.can_see_project()` already extended for derivation | t-139 Phase 2 | Migration 055 added the partner-unit branch; visibility predicate is the canonical entry. |
| `project_teams.role` enum is `{lead, of_counsel, associate, senior_pa, pa, local_counsel, expert, observer}` | t-138 + t-139 | `pg_constraint` on `project_teams_role_check`. Confirms eight values; `observer` is the read-only one. |
| Sidebar has a bell badge `id="sidebar-inbox-badge"` for approvals | t-138 | `frontend/src/components/Sidebar.tsx:118`. Same pattern reused for chat unread badge. |
| BottomNav has exactly 5 slots (Start / Projekte / + / Agenda / Menü) | mobile UX constraint | `frontend/src/components/BottomNav.tsx`. Adding chat to bottom-nav would need swap-out — deferred to per-project tab on mobile. |
| Migration tracker is at version 56 (`056_user_views`) | t-144 A1 | `paliad_schema_migrations` row. Next migration is **057**. |
| `paliad.notes` exists as annotations on deadlines/appointments/project_events | data model v2 | Different concept from chat (annotations, not conversation). Document the distinction so they don't collide. |
| Single web replica today on Dokploy | docker-compose.yml | One `web` service, no horizontal scaling. SSE in-process bus is safe v1; document multi-replica migration path. |
| `feature-roadmap.md` mentions "AI chat" | feature-roadmap.md | This is a different concept (Claude-grounded RAG over paliad content, blocked by no-Anthropic-API decision). Reserve `/chat` for human-to-human; AI chat goes elsewhere if it ever ships (`/ask`, `/assist`, etc.). |
**Doc-vs-live conflicts encountered:** none material. CLAUDE.md and memory are consistent with the live substrate for this task.
---
## §2 What v1 is and what it isn't
### 2.1 In scope (v1)
- **Per-project threads**, one chat per project node. Visible to: same set as `can_see_project()` (direct + ancestor + derived). One thread auto-provisioned on first access.
- **Direct messages (DMs)**: 1:1 + small-group ad-hoc. Recipient picker pulls from any user the caller can already see (i.e. someone who shares a visible project).
- **Plain-text + Markdown subset** (bold, italic, code inline + block, bullet/numbered lists, blockquote, auto-linked URLs). No headings, no images, no inline HTML.
- **@mentions** and **entity references** (`#frist-<short_id>`, `#projekt-<slug>`, `#termin-<short_id>`, `#approval-<short_id>`).
- **Edit** within 5 min, by author. Tombstone-style **delete** by author or admin.
- **Real-time delivery** via SSE.
- **In-app sidebar badge** with unread count.
- **Read marker** per (user, thread).
- **System auto-post**: when an approval request is created on this project's deadlines/appointments, system message in chat ("Anna hat Genehmigung angefordert: …"). One auto-event class only.
- **Chat tab on `/projects/{id}`** (deep-link entry).
- **Top-level `/chat` page** (global view + DM landing).
### 2.2 Out of scope (v1, deferred)
| Feature | Why deferred | Phase |
|---|---|---|
| PWA push notifications | Needs VAPID + push subscription endpoint + SW push handler. Non-trivial; chat MVP works without it. | 2 |
| Email digest of unread chats | Reminder system already saturates email; digest math + SMTP load. | 2 |
| File attachments | `paliad.documents` already exists as the canonical document store; chat reuse is a Phase 2 plumbing exercise. | 2 |
| Cross-thread search | Postgres FTS + visibility join is a separate optimisation. v1 has thread-scoped LIKE search. | 2 |
| Per-deadline / per-termin micro-threads | High-noise risk. Project chat with `#frist-…` references covers most uses. | 3 |
| Partner-unit room ("cross-cutting team room") | Semantically maps to a partner_unit-scoped chat; v2 once project chat usage validates. | 3 |
| Reactions (👍 / 👎) | Issue body lists this as Phase 2. | 2 |
| Threaded sub-replies (Slack-style) | UX complexity + count-math change. Flat threads for v1. | 3 |
| End-to-end encryption | HLC's storage assumptions are server-trusted; defer. | — |
| External-firm chat (opposing counsel etc.) | Compliance + identity boundary. Out of scope, possibly forever. | — |
---
## §3 Sub-design A — Surface set, visibility, permissions
Answers Q1, Q2, Q3, Q19, Q20.
### 3.1 Surface set (Q1)
**Recommendation: project chat + DMs in v1. Defer per-deadline/per-termin/partner-unit/topical.**
| Surface | v1? | Rationale |
|---|---|---|
| Per-project | ✅ | Already-resolved team set, contextual references, replaces "@channel"-style coordination on a project. The high-leverage default. |
| DM (1:1) | ✅ | Replaces "schick mir kurz das Aktenzeichen" WhatsApps. Recipient set = anyone the caller can see. |
| DM (small group, ad-hoc 38) | ✅ | Same plumbing as 1:1 — participants set instead of pair. No project context required. |
| Per-deadline | ❌ Phase 3 | High-noise risk; project chat with `#frist-1234` reference does 95% of the same work. Revisit if usage shows demand. |
| Per-termin | ❌ Phase 3 | Same reasoning as per-deadline. |
| Partner-unit room | ❌ Phase 3 | Maps cleanly onto `partner_units` once we see the surface gain traction. Extra surface for v1 = extra surface to maintain. |
| Cross-cutting topical rooms | ❌ Defer | No clear v1 use case; would need user-driven creation + naming + discovery. Wait for organic demand. |
### 3.2 Visibility model (Q2 — hierarchy)
**Recommendation:**
- **Each project node has its own thread.** A `Client` chat is a separate thread from its child `Litigation`, which is separate from each `Patent` and `Case`. Threads are NOT aggregated up the hierarchy.
- **Read access per thread** = the existing `paliad.can_see_project(project_id)` predicate (which already includes direct + ancestor team, derived partner-unit members, and global_admin). This means a member added at `Client` level sees the Client thread *and* every descendant's thread (because they can already see those projects' deadlines/appointments). Conversely, a member added only at `Case` level sees only the Case thread.
- **Why not aggregate down?** Aggregating ("Client thread = union of all descendant threads") breaks privacy: Case 14-vs-Müller chat content would surface in the Siemens AG Client thread, visible to all Siemens AG team members. Each project-level thread is its own boundary.
- **Why use per-thread visibility = `can_see_project`?** It mirrors every other visibility decision in paliad (deadlines, appointments, events, approvals). One predicate, one mental model. If t-139's derivation rules change, chat tracks for free.
**Practical example:**
```
Client: Siemens AG ← thread S
├─ Litigation: UPC München patent X ← thread L1
│ ├─ Patent: EP1234567 ← thread P1
│ │ └─ Case: 14-vs-Müller ← thread C1
│ └─ Patent: EP7654321 ← thread P2
└─ Litigation: EPO Opposition ← thread L2
```
A member added at `Client` (Siemens AG) sees S, L1, L2, P1, P2, C1. A member added only at `Case 14-vs-Müller` sees only C1. A derived partner-unit member attached at L1 sees L1, P1, P2, C1.
**Anti-feature flagged:** no "broadcast to whole subtree" on send. If a lead wants to message everyone on every Siemens AG thread, they post to the `Client`-level thread; sub-thread members do not get cross-posted. This is intentional — broadcast is a separate UX (Issue #7 bulk team email) and shouldn't be smuggled into chat.
### 3.3 Approval flow integration (Q3 — t-138 cross-cut)
**Recommendation: chat does NOT replace inbox or email for approval. Instead, on approval-request creation, post a system message into the project chat with a deep-link to `/inbox`.**
Rationale:
- Approvals are structured (approve/reject buttons, decision_kind, audit). Chat is unstructured. Conflating them dilutes the structure.
- But chat is where the team's eyeballs live ambiently. Posting "📌 Anna hat Genehmigung angefordert: Frist 16.05. (Replik einreichen). [Zur Genehmigung →]" surfaces the request without forcing anyone to refresh /inbox.
- **De-dup with email + bell:** the system post is informational only. Email reminder + bell badge stay primary signals. If the approver opens the chat first and clicks the deep-link, they reach /inbox; the bell decrements as soon as they act there.
**Mechanism:**
- `ApprovalService.Submit*` calls `ChatService.PostSystemMessage(threadID, kind="approval_requested", refs={approval_id})` inside the same tx as the approval row insert. If chat post fails, log + continue (approval is the load-bearing record; chat is observability).
- One auto-event class only. NOT every deadline-created / appointment-created. Reason: existing Verlauf already captures those; chat would become a duplicate event log.
- System messages render with a distinct chip/style (`.chat-system-message`) — non-author, no edit/delete affordance for users.
**Anti-feature flagged:** approval *decisions* (approve/reject/revoke) do NOT auto-post. Only the *request* posts. This keeps signal density manageable.
### 3.4 Who can read + write (Q19, Q20)
**Recommendation:**
- **Read**: anyone with `can_see_project` access (direct + ancestor + derived + global_admin). Same predicate as deadlines/appointments. No further gating.
- **Write**: same set, minus:
- `observer` — always read-only on chat (mirrors observer's read-only contract on deadlines/appointments).
- `local_counsel` and `expert` — opt-in per project via a new `project_teams.chat_access` boolean. Default `false` for these two roles, `true` for everyone else. Project lead or global_admin can flip the toggle on `/projects/{id}/settings/team`.
**Schema delta (in 057):**
```sql
ALTER TABLE paliad.project_teams
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
UPDATE paliad.project_teams SET chat_access = false
WHERE role IN ('local_counsel', 'expert');
CREATE INDEX project_teams_chat_idx
ON paliad.project_teams (project_id, user_id) WHERE chat_access = true;
```
**Why a boolean instead of a separate `chat_access_role` enum?** External counsel/expert participation in chat is binary in practice ("included or excluded"). Granular ladder isn't needed. If product later wants "external can read but not write", we revisit.
**Why default ON for internal roles?** Path of least surprise: paliad already gives them visibility on all project artifacts; chat read+write is the same trust level.
**Why default OFF for external?** Compliance is the marquee differentiator m cited. External counsel chatting in paliad creates audit/disclosure surface that internal counsel may not anticipate. Default OFF puts the lead in control.
**Derived members (partner-unit derivation, t-139)**: read = visibility (yes). Write = also yes by default (they can already see the project's other artifacts; chat is no more privileged). Derived members do NOT need `chat_access=true` — that flag is on `project_teams` only, which derived members don't appear in. The derivation branch in the read query already covers them; for write, the service-layer check is "caller has any access (direct/ancestor/derived/admin) AND if direct, role != observer AND chat_access != false".
**Service-layer write predicate (Go):**
```go
func (s *ChatService) canPostToProject(ctx context.Context, callerID, projectID uuid.UUID) (bool, error) {
// global_admin shortcut
if isGlobalAdmin, _ := s.users.IsGlobalAdmin(ctx, callerID); isGlobalAdmin {
return true, nil
}
// Direct/ancestor membership with role != observer AND chat_access = true
var directOK bool
err := s.db.QueryRowxContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.role <> 'observer'
AND pt.chat_access = true
AND pt.project_id = ANY(string_to_array((SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
)`, callerID, projectID).Scan(&directOK)
if err != nil { return false, err }
if directOK { return true, nil }
// Derived (partner-unit) membership: observer/external-flag not relevant — derivation has no role
return s.derivation.IsDerivedMember(ctx, callerID, projectID)
}
```
`canRead` is the simpler `can_see_project` mirror — no observer/external gating.
---
## §4 Sub-design B — Real-time, content, persistence
Answers Q4Q15, Q21.
### 4.1 Real-time architecture (Q4)
**Recommendation: Server-Sent Events (SSE).**
| Option | v1 fit | Notes |
|---|---|---|
| (a) Polling | ❌ | Cheap to ship but lossy under tab-sleep, doubles the API load on every active tab. Already a pain point for the bell badge. |
| (b) **SSE** | ✅ | One-way push, native in Go's `net/http` via `http.Flusher`, EventSource auto-reconnects, single endpoint, no per-message connection. Traefik forwards `text/event-stream` with no special config beyond disabling response buffering. |
| (c) WebSockets | Defer | Bidirectional we don't need (post is a regular POST + bus publish). Adds heartbeat/reconnect/sticky-session complexity. Worth it only if v2 surfaces typing-indicators or read-receipts that need bidi. |
**Endpoint shape:**
```
GET /api/chat/stream
Accept: text/event-stream
[Last-Event-ID: <message_id>] ← optional for resume
```
Server emits:
```
event: message
id: <message_id>
data: {"type":"message_created","thread_id":"…","message":{…}}
event: message
id: <message_id>
data: {"type":"message_edited","thread_id":"…","message_id":"…","body":"…","edited_at":"…"}
event: message
id: <message_id>
data: {"type":"message_deleted","thread_id":"…","message_id":"…"}
event: read
data: {"type":"read_advanced","thread_id":"…","user_id":"…","up_to_message_id":"…"}
: ping ← every 25s, keeps Traefik from reaping idle stream
```
Server-side: per-user goroutine subscribed to `ChatBus` (see §4.2). On bus event, filter against the user's visibility (cached at connect; invalidate on team-membership change), encode SSE frame, flush. Connection close = unsubscribe.
**Failure modes + mitigations:**
| Failure | Mitigation |
|---|---|
| Idle proxy reaper (Traefik default ~3min) | Heartbeat comment every 25s. |
| Backpressure if recipient connection is slow | Per-user channel has a small buffer (16). Overflow drops the slow consumer's connection (client EventSource auto-reconnects with `Last-Event-ID`, replay catches up). |
| Multi-replica fanout (future) | Bus interface allows swap to `pgnotify.ChatBus` (LISTEN/NOTIFY on `paliad_chat`) without touching ChatService. |
| HTTP/1.1 6-conn-per-origin browser cap | Document. Single tab = no issue. Multi-tab is a known SSE constraint; users with many tabs will see one tab's stream go silent. Rare in legal-team usage; defer. |
**Disable response compression on this endpoint** in handler (`w.Header().Set("Content-Encoding", "identity")`) to prevent Traefik from buffering.
### 4.2 Message bus (interface)
**`internal/services/chat_bus.go`**:
```go
type ChatEvent struct {
Kind string // message_created | message_edited | message_deleted | read_advanced
ThreadID uuid.UUID
MessageID uuid.UUID // for message_* events
Payload map[string]any
AudienceFn func(uid uuid.UUID) bool // visibility filter — applied per subscriber
}
type ChatBus interface {
Publish(ctx context.Context, ev ChatEvent) error
Subscribe(ctx context.Context, userID uuid.UUID) (<-chan ChatEvent, func())
}
// Default: in-process. Per-user channel registry under sync.Map.
// Future: postgresChatBus uses pg_notify(channel="paliad_chat") for fanout.
```
**Why an interface from day 1?** paliad's deploy is single-replica today (docker-compose, one `web` container on Dokploy). When/if we scale to N, swap in the pg_notify implementation; no callsite changes. Cheap insurance.
### 4.3 Notification path (Q5)
**Recommendation: in-app sidebar badge ONLY in v1. Email digest deferred. PWA push deferred.**
| Channel | v1 | Phase 2 | Phase 3+ |
|---|---|---|---|
| In-app sidebar Chat unread badge | ✅ | | |
| Browser tab title flash on incoming message (foreground tab on chat surface) | ✅ (cheap) | | |
| `Notification` API (foreground, opt-in per browser permission) | ✅ (cheap) | | |
| Email digest of unread-since-last-login | | ✅ | |
| PWA push (background, requires VAPID + SW push handler) | | | ✅ |
| CalDAV alarm | ❌ | ❌ | ❌ Wrong channel — calendar is for time-anchored events. |
**Rationale for deferring PWA push:**
- paliad's `frontend/public/sw.js` is currently a 90-line cache-only worker. Adding push needs:
1. A new `addEventListener('push', …)` and `addEventListener('notificationclick', …)` block.
2. VAPID keypair generation + secure storage (env vars).
3. New table `paliad.push_subscriptions(user_id, endpoint, p256dh, auth, user_agent, created_at)`.
4. Server-side `web-push` Go lib (e.g. `github.com/SherClockHolmes/webpush-go`).
5. New endpoint `POST /api/push/subscribe` + permission-prompt UX.
- Worth ~600800 LoC and a separate review cycle. Don't bundle into chat MVP. Once chat usage is validated, push graduates as a Phase 2 task and serves chat + approvals + reminders together (one push pipeline, multiple producers).
**Rationale for deferring email digest:**
- Mail volume is already a friction point — t-paliad-064 just collapsed reminders into bundled digests. Layering an unread-chat email on top would re-saturate.
- Once usage shows it's needed, the digest can compose with the existing morning/evening slot reminder pipeline.
### 4.4 Read / unread + delivery state (Q6)
**Recommendation: per-(user, thread) last-read marker. No per-message read receipts.**
```sql
CREATE TABLE paliad.chat_reads (
user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
thread_id uuid REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, thread_id)
);
```
**Unread count for sidebar badge:**
```sql
SELECT COUNT(*)
FROM paliad.chat_messages m
WHERE m.thread_id IN (<visible thread ids for caller>)
AND m.deleted_at IS NULL
AND m.author_id <> $caller
AND (
NOT EXISTS (SELECT 1 FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
OR m.created_at > (SELECT cr.last_read_at FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
);
```
Optionally cap at 99+ in UI.
**Why no per-message read receipts?** Privacy concern (legal team won't want "Anna saw your message 14 min ago, didn't reply"). UX clutter. Slack made the same call (workspace-default).
**`last_read_message_id` on `chat_reads`** is for "scroll to the boundary" UX — when you open a thread, the client scrolls to the message immediately above the marker and inserts a "neue Nachrichten" divider. The boundary is sticky until you mark-read again.
### 4.5 Message body (Q7)
**Recommendation: stored as Markdown source, rendered with a small whitelisted renderer.**
| Render | v1 | Notes |
|---|---|---|
| Bold (`**`/`__`) | ✅ | |
| Italic (`*`/`_`) | ✅ | |
| Inline code (` `` `) | ✅ | |
| Code block (```` ``` ````) | ✅ | Three-backtick fenced; preserves whitespace. |
| Bullet list | ✅ | |
| Numbered list | ✅ | |
| Blockquote (`>`) | ✅ | |
| Auto-link URLs | ✅ | `https?://` patterns auto-wrap as anchor with `target=_blank rel=noopener`. |
| Headings (`#`) | ❌ | Chat ≠ doc. Strip to plain text on render. |
| Images / embeds | ❌ | Use attachments (Phase 2). |
| Inline HTML | ❌ | Always sanitised out. |
| Raw URLs | ✅ | Auto-link them. |
**Library choice:** Server-side, use a small custom renderer or `github.com/yuin/goldmark` with a whitelist extension. Either works; I lean toward a tiny custom one (~150 LoC) because the subset is small and goldmark imports a lot. Frontend is render-only — server delivers HTML-rendered + raw source; client picks based on edit/view state.
**Storage:** raw Markdown source in `paliad.chat_messages.body`. Rendered HTML is computed on read (cheaply; cache in a separate column if benchmarks ever justify). Rendering on read keeps mention/entity-ref resolution dynamic (a deleted deadline's `#frist-…` chip degrades to a dimmed pill instead of a stale link).
### 4.6 Mentions + entity references (Q8)
**Recommendation: yes for v1 — `@user` + `#frist-…` + `#projekt-…` + `#termin-…` + `#approval-…`.**
**Resolution:**
- **Compose-side**: client-side autocomplete on `@` and `#`. Hits `/api/chat/autocomplete?q=…&context=<thread_id>` — server returns a small list scoped to the thread's visibility (mentions: thread members; entities: project's items + globally-visible items the caller can see).
- **Persist-side**: on POST, server parses tokens `@<slug>` / `#<entity>-<short_id>`, resolves to UUIDs, stores in `paliad.chat_mentions` (for users) and in `metadata.entity_refs` JSON (for entities). Original Markdown source preserves the `@anna` / `#frist-1234` syntax.
- **Render-side**: on read, server renders tokens as HTML with deep-links: `<a class="chat-mention" href="/team#user-<uuid>">@anna</a>`, `<a class="chat-entity-ref entity-frist" href="/deadlines/<id>">Frist 1234</a>`. Rendering re-checks visibility per recipient — invisible references render as dimmed `<span class="chat-entity-ref dimmed">[#frist-…]</span>`.
**Notification effect**: a mention drives a unread-count bump. A future Phase 2 enhancement: a separate "Erwähnungen" tab on `/chat` that filters to messages mentioning the caller, with a separate badge. v1 just lifts the unread-count visibility (mention or no mention, the badge ticks).
### 4.7 Attachments (Q9)
**Recommendation: out of scope for v1. Reference existing `paliad.documents` via `#dok-<id>` is a v2 pattern.**
Rationale:
- `paliad.documents` is the canonical document store with metadata (folder, tags, ACL planned). Adding a parallel attachment surface from chat would create two upload pipelines.
- v1 chat references existing documents via entity-ref `#dok-<id>` (deferred until v2 for the implementation; the syntax is reserved now).
- v2 attachment flow: drag-and-drop into chat → uploads into `paliad.documents` → message body gains a `#dok-<id>` reference. Single document, two surfaces.
### 4.8 Edits / deletions (Q10)
**Recommendation:**
- **Edit** allowed within 5 min of post. After 5 min, edit affordance is hidden — correct via reply.
- **Delete** allowed at any time by author. Soft-delete: `deleted_at` set, body replaced server-side with the rendered tombstone "Diese Nachricht wurde gelöscht." in the API payload (DE/EN per `users.lang`). Client renders the tombstone in muted styling. Mentions/entity-refs in deleted messages are preserved server-side (audit) but suppressed in render.
- **Admin override**: global_admin can delete any message at any time. Audit-marked: `metadata.deleted_by_admin = <admin_id>` and a system-message in the same thread "Admin hat eine Nachricht entfernt." (no body content disclosed).
**Why 5 min?**
- Long enough for typo undo, short enough to keep audit trust ("the message you just read isn't the one stored an hour later").
- Mirrors many legal-team chat tools (Slack's default-edit-window can be configured; Teams has 0 by default but admin can extend).
- Edit shows `(bearbeitet)` chip with tooltip showing `edited_at`.
**Why soft-delete only?** Compliance: paliad may need to demonstrate message provenance even after deletion. Soft-delete keeps the row + author + created_at; only `body` is hidden. Hard-delete is escalation-only (manual SQL by global_admin if legal forces).
### 4.9 Replies / threading (Q11)
**Recommendation: flat threads in v1. Slack-style sub-threads deferred.**
A flat thread means every message in the project chat lands at the bottom, ordered chronologically. To reply to a specific message, quote it (`>` Markdown blockquote) or @mention the author — same pattern Twitter/Mastodon use successfully without sub-threads.
**Why flat?**
- Sub-threads add: a `parent_message_id` column, a parent-thread-summary fold-out UI, "thread of threads" navigation, separate unread counts for thread vs sub-thread.
- For project-team chat (515 active members per project), flat is cleaner. Sub-threads pay off in larger channels (50+ members, parallel conversations).
- Re-introduce in v2 if usage shows specific demand for parallel parlay.
### 4.10 Search (Q12)
**Recommendation: thread-scoped search in v1. Cross-thread search deferred.**
- Thread-scoped: `WHERE thread_id = $1 AND body ILIKE '%' || $2 || '%' AND deleted_at IS NULL` — sub-second on threads up to ~10k messages. Above that, add a Postgres FTS index in v2.
- Cross-thread: would need `paliad.chat_messages` FTS + visibility join — workable but separable. Defer to Phase 2 once we know the cross-thread use case.
### 4.11 Storage schema (Q13)
**Migration 057:**
```sql
-- paliad.chat_threads ----------------------------------------------------------
CREATE TYPE paliad.chat_thread_kind AS ENUM ('project', 'dm');
CREATE TABLE paliad.chat_threads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind paliad.chat_thread_kind NOT NULL,
project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
-- DM 1:1 / small group: participants in chat_thread_participants;
-- project: visibility predicate (no rows in chat_thread_participants).
title text, -- DM small-group: optional user-supplied; project: NULL (use project name)
created_by uuid REFERENCES paliad.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
last_activity timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chat_thread_kind_consistency CHECK (
(kind = 'project' AND project_id IS NOT NULL) OR
(kind = 'dm' AND project_id IS NULL)
)
);
-- One project = one thread (idempotent provisioning).
CREATE UNIQUE INDEX chat_threads_project_idx ON paliad.chat_threads (project_id) WHERE kind = 'project';
CREATE INDEX chat_threads_activity_idx ON paliad.chat_threads (last_activity DESC);
-- paliad.chat_thread_participants ---------------------------------------------
CREATE TABLE paliad.chat_thread_participants (
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
joined_at timestamptz NOT NULL DEFAULT now(),
role text NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')),
-- 'admin' on a DM = the creator who can add/remove participants. Project chats have no rows here.
PRIMARY KEY (thread_id, user_id)
);
CREATE INDEX chat_thread_participants_user_idx ON paliad.chat_thread_participants (user_id);
-- paliad.chat_messages --------------------------------------------------------
CREATE TABLE paliad.chat_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- author_id NULL = system message (auto-post)
body text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
-- metadata: { system: true, system_kind: "approval_requested", entity_refs: [...], deleted_by_admin: <uuid>, … }
edited_at timestamptz,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX chat_messages_thread_idx ON paliad.chat_messages (thread_id, created_at DESC);
CREATE INDEX chat_messages_author_idx ON paliad.chat_messages (author_id, created_at DESC);
-- paliad.chat_reads -----------------------------------------------------------
CREATE TABLE paliad.chat_reads (
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, thread_id)
);
-- paliad.chat_mentions --------------------------------------------------------
CREATE TABLE paliad.chat_mentions (
message_id uuid NOT NULL REFERENCES paliad.chat_messages(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
PRIMARY KEY (message_id, user_id)
);
CREATE INDEX chat_mentions_user_idx ON paliad.chat_mentions (user_id);
-- paliad.project_teams.chat_access -------------------------------------------
ALTER TABLE paliad.project_teams
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
UPDATE paliad.project_teams SET chat_access = false
WHERE role IN ('local_counsel', 'expert');
-- RLS ------------------------------------------------------------------------
ALTER TABLE paliad.chat_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_thread_participants ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_reads ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_mentions ENABLE ROW LEVEL SECURITY;
-- Service-role bypasses RLS (paliad's pgx pool runs as service-role per t-paliad-088 lesson #2).
-- Policies exist for any future direct-DB query path; service-layer is the load-bearing gate.
-- (RLS predicates omitted from this design doc; sketch in §13 of impl plan.)
```
**`chat_threads.kind = 'dm'` uniqueness for 1:1**: enforce via service layer (sort participant UUIDs, check existing thread with exact participant set). Not in the schema CHECK because participants live in another table.
### 4.12 Retention (Q14)
**Recommendation: forever in v1. Soft-delete only. Phase 2 = export/archive flow.**
Compliance: HLC may need permanent archival of project-related conversations. Forever-storage is the safest default; cheaper than implementing rolling-window deletion + getting it wrong.
`deleted_at` is soft-delete by user/admin action; no time-based purge in v1.
**Phase 2** features for retention/compliance:
- Export thread to PDF/JSON (audit trail).
- Per-project retention override (e.g. Case closed → archive after 6 months).
- Search across archived (read-only) threads.
### 4.13 Audit / Verlauf integration (Q15)
**Recommendation: chat does NOT appear in Verlauf by default. Optional "Pin to Verlauf" affordance on individual messages → creates a `note` (Phase 2).**
Rationale:
- Verlauf already has 18 distinct `event_type` values (`deadline_*`, `appointment_*`, `checklist_*`, `note_*`, `project_type_changed`). Adding `chat_message_*` events for every chat post would dilute signal — Verlauf should answer "what changed on this matter" not "what was said".
- The "Pin to Verlauf" affordance lets users explicitly promote a chat message to a `paliad.note` (and emit a `note_created` event_type — the existing pattern). Phase 2; reserve the UX hook now.
### 4.14 PWA push (Q21)
**Recommendation: defer to Phase 2.** See §4.3 above for the cost/value reasoning. v1 ships without push; users get unread badge + tab-flash + foreground `Notification` API.
---
## §5 Sub-design C — Integration with existing surfaces
Answers Q16Q18.
### 5.1 Sidebar entry (Q16)
**Recommendation: BOTH — a top-level `Chat` sidebar entry with global unread badge AND a per-project `Chat` tab on `/projects/{id}` deep-linking the same thread.**
**Sidebar:**
```
Übersicht
├─ Home
├─ Dashboard
├─ Agenda
├─ Inbox (bell badge — approvals)
├─ Chat (new — chat badge — unread messages) ← new
└─ Team
Arbeit
└─ …
Meine Sichten
└─ …
```
Position: directly under `Inbox`. Same group ("Übersicht"). Same badge pattern (`id="sidebar-chat-badge"`).
**`/chat` page** (new top-level):
- Two-pane layout: thread list left (recently-active first), active thread right (messages + composer).
- Thread list shows: project chats the user has access to (sorted by `last_activity DESC`), DMs (sorted by `last_activity DESC`).
- Tabs: `Alle` (default), `Projekte`, `DMs`, `Erwähnungen` (Phase 2). Visual style mirrors `/inbox` tab chips.
**Per-project Chat tab on `/projects/{id}`:**
- Adds a new "Chat" tab next to existing tabs (Übersicht / Fristen / Termine / Verlauf / Team / …). Tab opens the same project thread, full-width-in-tab.
- Deep-links: `/projects/<id>?tab=chat` and `/projects/<id>/chat` (server resolves both).
**Mobile (BottomNav)**:
- BottomNav slots are full at 5 (Start / Projekte / + / Agenda / Menü). Don't swap a slot — chat surfaces from `Menü` and from per-project `Chat` tab. Defer dedicated mobile slot to Phase 2 once usage justifies.
### 5.2 Custom Views (#5) integration (Q17)
**Recommendation: chat messages are NOT a 5th source in the ViewService union.**
Rationale:
- ViewService unions four kinds: deadline, appointment, project_event, approval_request. Each is a *time-anchored event* with a structured semantics ("Frist X due on Y"). Chat messages are conversation, not events.
- Adding a `chat_message` source would dilute the substrate's purpose and produce noisy Custom Views ("show me everything in the next 30 days" → 80% chat noise).
- **Mentions and entity-refs do NOT cross over either.** A `#frist-1234` reference inside chat doesn't promote the chat message into the deadline's audit; the reference is a navigation aid, not an audit fact.
**Phase 2 escape hatch**: if demand emerges for "show me activity (chat + events) on this project", introduce a new `chat_activity` synthesised source that emits one row per *day-bucket-with-N-messages*, not per-message. That keeps Custom Views unflooded while exposing the "this project has been busy" signal. Reserve the source name; don't implement v1.
### 5.3 Bulk team email (#7) overlap (Q18)
**Recommendation: distinct surfaces, deliberate split.**
| Use case | Bulk email (#7) | Chat |
|---|---|---|
| Team-wide announcement, no reply expected ("Server-Wartung am Montag 18 Uhr") | ✅ | — |
| Coordination on a specific matter ("Anna, kannst du auf meine Frist 16.05. drauf schauen?") | — | ✅ |
| Process reminders / quarterly newsletters | ✅ | — |
| "Wer sieht heute den 14:00 hearing-call?" | — | ✅ |
| External-counsel briefing | ✅ (mail) | — (chat is internal-only by default) |
| Hot-fix coordination during litigation prep | — | ✅ |
| Birthday / kudos (if product wants it) | — | ✅ |
**Pattern:**
- **Email** is broadcast, archive-friendly, no expectation of synchronous reply, lives in user's regular inbox alongside client mail.
- **Chat** is back-and-forth, ambient, threaded, scoped to a project's team or a small DM group.
The two coexist; users self-select. No automatic cross-posting. If a user writes a chat post that is "really" a broadcast announcement, that's a soft heuristic the product can teach later (Phase 3 nudge: "Looks like a broadcast — send as email instead?").
---
## §6 Inventor follow-up questions for m
Beyond the 21 questions in the issue body, my design surfaced a few I cannot lock without a call:
| # | Question | Recommendation |
|---|---|---|
| Q22 | **DM creation policy**: anyone-to-anyone, or scoped to "people I share a project with"? | Recommend: scoped to "people I share at least one visible project with" — keeps DMs inside the matter graph, prevents random cross-firm pings. global_admin always reachable. |
| Q23 | **DM small-group cap**: hard limit on DM participants? | Recommend cap at **8** (informal coordination tier; above that, a project chat or partner-unit room is right). Not a hard schema cap; service-layer + UI cap. Lift later if we see demand. |
| Q24 | **Project chat auto-provision timing**: lazily on first read, or eagerly on project create? | Recommend lazy. Most projects never get chatter; lazy provisioning saves rows + noise on `/projects` list. Once demand is shown, switch to eager (one-line change). |
| Q25 | **System auto-post audience**: project chat post is visible to ALL thread members, including external counsel + observer. Is approval-request system-post leaking signal that external counsel shouldn't see? | Recommend: respect existing `chat_access` flag — observer reads, external counsel reads only if their `chat_access=true`. The same predicate as any chat post; the system just authors instead of a user. |
| Q26 | **Edit window length**: 5 min as recommended, or shorter (1 min) / longer (15 min) / no edit at all? | Recommend 5 min. 1 min is too short for "wait did I tag the right person", 15 min is long enough for someone else to have read+replied based on the original. |
| Q27 | **Markdown subset**: include or exclude blockquote? Tables? Strikethrough? | Recommend: blockquote ✅ (quoting prior message is the flat-thread alternative to sub-threading). Tables ❌ (unusual in chat, complicates renderer). Strikethrough ❌ (chat ≠ doc; rare and ambiguous). |
| Q28 | **`@everyone` / `@team`**: support a "ping the whole project team" mention? | Recommend: NO in v1. Spam risk. Lead can post a normal message; team members on the thread already see it. Phase 2: optional `@team` for project leads only, with confirmation prompt. |
| Q29 | **Chat unread badge: count messages or count threads?** | Recommend: count *messages* with caps at 99+. Threads-with-unread is easier on the eye but obscures volume; messages match user mental model of "you have N things to read". |
| Q30 | **Sound on incoming message** (foreground tab)? | Recommend: NO by default. Opt-in setting (`users.chat_sound_enabled`) deferred to Phase 2. Lawyers in court rooms with their phone open is a real failure mode. |
| Q31 | **Default landing on `/chat`**: most-recently-used thread, "Alle" thread list, or empty placeholder? | Recommend: most-recently-used thread (mirrors `/views` MRU pattern from t-144). First-time users land on an empty-state "Wähle einen Thread links". |
| Q32 | **Chat post triggers `last_activity` bump** on associated project (drives sidebar sort, dashboard "recent activity") — yes/no? | Recommend: yes for chat threads themselves (sort thread list). NO for `paliad.projects.last_modified` (chat shouldn't ride sibling sort signals — that's reserved for case-substantive changes). |
m's go on these locks the design. If any answer flips, I rev the doc before handing to the implementer.
---
## §7 Trade-offs and risks
### 7.1 Adoption risk (the elephant in the room)
**The biggest risk is not technical — it's whether teams actually use this.** HLC colleagues already have:
- WhatsApp + Telegram for fast informal coordination.
- Microsoft Teams / Outlook chat for firm-internal IM (assumption — verify).
- Email for formal asynchronous comms.
paliad chat would need to attract `"Anna, kannst du auf meine Frist 16.05. drauf schauen?"` away from those tools. The differentiator m cited in the issue is compliance + context-rich (auto-resolve `#frist-1234`, team set is pre-derived). That's plausible — but the cost of building it is real, and if PA colleagues stick to WhatsApp, paliad chat becomes a half-empty room that signals "this product doesn't know its users".
**Recommendation before implementation:** m asks two PA colleagues from different offices ("would you actually use this if it existed?", "what would make you switch from WhatsApp?"). Either keeps you honest or surfaces feature gaps the design doesn't cover.
If adoption looks weak, alternative scopes worth considering:
- **A: ship project chat only (no DM).** Project context is the real differentiator; DM is what WhatsApp does well. Less surface, less work, less risk of half-empty.
- **B: ship `@mention + reply` as a comment thread on each deadline/termin first** — closer to the Verlauf pattern, lower lift, and validates the idea before the full chat surface.
### 7.2 Single-replica SSE constraint
Today's docker-compose is one `web` container. SSE works fine. If we ever scale (multi-Dokploy-replica, blue-green deploy with overlap), in-process bus drops cross-replica messages.
**Mitigation:** abstract `ChatBus` interface from day 1. Future `pgnotify.ChatBus` implementation is ~80 LoC and a one-line wiring change. Document this in `internal/services/chat_bus.go`.
### 7.3 Observer-write semantics
`observer` role is read-only for chat per recommendation. There's a UX edge case: an observer who *thinks* they're a regular member (because everyone else is chatting) and gets a write-disabled composer. Mitigate with a clear empty composer hint: "Du bist Beobachter:in für dieses Projekt — Lesezugriff nur." Same pattern as observer's read-only Frist edit.
### 7.4 External counsel default OFF chat
Defaulting `chat_access=false` for `local_counsel`/`expert` is the right compliance default but creates onboarding friction: the first time external counsel is added to a project, the lead has to explicitly toggle them in. **Mitigate** with a one-time hint in the team-add modal: "Externe Anwält:in/Sachverständige:r — Chat ist standardmäßig deaktiviert. Aktivieren?".
### 7.5 Markdown sanitisation correctness
Hand-rolling a small Markdown subset risks XSS through subtle edge cases (`[click](javascript:…)`, malformed image URI, etc.).
**Mitigate:**
- Escape all rendered text first, then apply whitelisted Markdown tokens.
- For URLs: validate `https?://` prefix with stdlib `url.Parse`; reject everything else.
- Add a render test suite with known-bad payloads (data URIs, javascript: URIs, broken closures).
- If we end up importing goldmark anyway, lean on its strict mode + a custom rendering walker.
### 7.6 Chat as a Verlauf-substitute
Risk that users start treating chat as the audit log ("I told Anna in chat to extend that deadline"). Verlauf is the audit; chat is conversation. Mitigate by:
- The Phase 2 "Pin to Verlauf" affordance promotes specific chat messages to notes.
- UX copy on the chat composer: "Notizen am Vorgang? → Verlauf." (small hint, not a wall).
### 7.7 Mobile keyboard + composer + bottom-nav
Mobile keyboards on iOS Safari overlap fixed bottom-nav elements. The composer needs to play nicely with that — anchor at viewport-bottom but adjust on focus. Standard pattern (the existing checklist comment composer probably has the same issue solved). Worth a quick check in implementation, not a design blocker.
### 7.8 chat_messages explosion
Multiplied across all paliad projects, chat could grow to millions of rows over years. Indexes on `(thread_id, created_at DESC)` keep reads fast. PG handles 10M+ rows with ease at this index shape. Storage cost is negligible. Document the size projection in the impl plan but don't pre-optimize.
---
## §8 Phasing
**Phase 1 (chat MVP — bundled v1, single PR):** ~35004500 LoC
1. Migration 057 (chat schema + `project_teams.chat_access`).
2. `ChatService` + `ChatBus` interface + in-process implementation.
3. HTTP endpoints (8 in §10).
4. SSE stream endpoint with heartbeat + Last-Event-ID resume.
5. `frontend/src/chat.tsx` + client `client/chat.ts` + Markdown renderer.
6. `frontend/src/components/Sidebar.tsx` updated with Chat entry + badge.
7. Per-project Chat tab on `/projects/{id}`.
8. Approval auto-post wiring in `ApprovalService.Submit*`.
9. ~80 i18n keys DE+EN.
10. CSS for chat shell + bubbles + mention chips + composer.
**Phase 2** (~2000 LoC each, separate PRs as demand justifies):
- Email digest of unread chats (composes with reminder pipeline).
- PWA push notifications (VAPID + SW push handler + subscription endpoint).
- File attachments (chat → `paliad.documents`).
- Cross-thread search (FTS index + global search).
- "Pin to Verlauf" affordance.
**Phase 3+** (defer until Phase 1+2 usage validates):
- Per-deadline / per-termin micro-threads.
- Partner-unit rooms.
- Reactions, sub-threads, `@team`, sound/Notification config UI.
- Topical/cross-cutting rooms.
**Optional Phase 1 split (if implementer prefers):**
- 1A — Schema + `ChatService` + REST endpoints + project chat shell. No DMs, no SSE (polling stub for unread badge).
- 1B — DMs + SSE + mentions + entity-refs + approval auto-post.
If the implementer splits, they own the call. Both 1A+1B in a single PR is ~4500 LoC; each in its own PR is ~2000-2500. m can decide on the split when locking the design.
---
## §9 Implementer recommendation
**Recommended worker: noether (this worktree)** or a fresh coder.
Pattern-fluent Sonnet work; nothing here requires Opus-level architectural reasoning past this design. The substrate (visibility predicate, project_teams shape, SSE handling, sidebar/badge pattern, ViewService precedent) is well-trodden — implementation is mostly composition.
NOT cronus per memory directive (cronus retired from paliad).
Expected files (Phase 1):
- `internal/db/migrations/057_chat.{up,down}.sql`
- `internal/services/chat_service.go`
- `internal/services/chat_bus.go`
- `internal/services/markdown.go` (small renderer)
- `internal/handlers/chat.go`
- `internal/handlers/chat_stream.go` (SSE)
- `internal/handlers/handlers.go` (route wiring under `if svc.Chat != nil`)
- `internal/services/approval_service.go` (auto-post hook on Submit*)
- `cmd/server/main.go` (`chatBus := services.NewInProcessChatBus(); chatSvc := services.NewChatService(pool, …, chatBus)`)
- `frontend/src/chat.tsx` (page shell)
- `frontend/src/projects-detail.tsx` (Chat tab integration)
- `frontend/src/client/chat.ts` (orchestration, EventSource, autocomplete, edit/delete, read marker)
- `frontend/src/client/markdown.ts` (render-side companion if any)
- `frontend/src/client/sidebar.ts` (badge + unread-count fetch)
- `frontend/src/components/Sidebar.tsx` (new Chat entry)
- `frontend/src/styles/global.css` (chat-shell + chat-bubble + chat-mention + chat-composer styles)
- `frontend/src/i18n.ts` (~80 keys DE+EN)
- `frontend/src/build.ts` (chat.html bundle)
---
## §10 HTTP endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/chat` | Chat shell page |
| GET | `/chat/dm/<thread_id>` | Deep-link to specific DM thread (server resolves visibility, redirects to /chat with state) |
| GET | `/api/chat/threads` | List threads visible to caller (project + DM), sorted by last_activity. Includes per-thread unread count. |
| POST | `/api/chat/dm` | Body `{ "participant_ids": [...], "title": "..." }`. Returns thread (idempotent for 1:1 by sorted participant set). |
| GET | `/api/chat/threads/<id>/messages?before=<msg_id>&limit=50` | List messages with cursor pagination. Returns rendered HTML + raw source per message. |
| POST | `/api/chat/threads/<id>/messages` | Post message. Body `{ "body": "..." }`. Server parses mentions/refs, inserts, publishes bus event. |
| PATCH | `/api/chat/messages/<id>` | Edit (5min author window). Body `{ "body": "..." }`. |
| DELETE | `/api/chat/messages/<id>` | Soft-delete (author or admin). |
| POST | `/api/chat/threads/<id>/read` | Body `{ "up_to_message_id": "..." }`. Updates `chat_reads`. |
| GET | `/api/chat/unread-count` | Sidebar badge. Returns `{ "total": N, "by_thread": {...} }`. |
| GET | `/api/chat/autocomplete?q=&context=<thread_id>` | Server-resolved mention/entity-ref autocomplete. |
| GET | `/api/chat/stream` | SSE long-lived; returns events filtered to caller's visibility. |
---
## §11 Frontend shape (Phase 1)
```
/chat [/chat]
┌───────────────────┬──────────────────────────────────────┐
│ THREADS │ Siemens AG · Litigation UPC München │
├───────────────────┼──────────────────────────────────────┤
│ Alle | Proj | DM │ ┌──────────────────────────────────┐ │
│ │ │ Anna · 14:23 │ │
│ ▶ Siemens AG · L. │ │ Hat jemand auf Frist #frist-1234 │ │
│ 3 ungelesen │ │ drauf geschaut? Replik bis Mo. │ │
│ ▷ EP1234567 │ └──────────────────────────────────┘ │
│ ▷ DM mit Anna │ ┌──────────────────────────────────┐ │
│ ▷ DM (3) UPC-Team │ │ ── neue Nachrichten ────────────│ │
│ │ ├──────────────────────────────────┤ │
│ │ │ Lukas · 14:30 │ │
│ │ │ Schau gleich rein, ist das EAU? │ │
│ │ └──────────────────────────────────┘ │
│ │ │
│ ├──────────────────────────────────────┤
│ │ ┌──────────────────────────────────┐ │
│ │ │ @anna danke! … ▶│ │
│ │ └──────────────────────────────────┘ │
└───────────────────┴──────────────────────────────────────┘
```
On mobile: thread list is a full page, tap → message page.
On `/projects/{id}?tab=chat`: messages pane only (thread list hidden), with project header above.
System-post visual:
```
┌───────────────────────────────────────────────┐
│ 🔔 Anna hat Genehmigung angefordert: │
│ Frist 16.05. (Replik einreichen) │
│ [ Zur Genehmigung → ] │
└───────────────────────────────────────────────┘
```
(Distinct background, no edit/delete affordance, deep-link button.)
---
## §12 Concrete recommendation summary
| # | Question | Recommendation |
|---|---|---|
| Q1 | Surface set v1 | Per-project + DMs |
| Q2 | Hierarchy visibility | Per-thread, predicate = `can_see_project` |
| Q3 | Approval cross-cut | System auto-post, no replacement |
| Q4 | Real-time arch | SSE |
| Q5 | Notification path | In-app badge + tab-flash + Notification API; defer push + email digest |
| Q6 | Read/unread | Per-(user,thread) last-read marker; no per-message receipts |
| Q7 | Body format | Markdown subset (no headings, no images) |
| Q8 | Mentions + refs | `@user`, `#frist-…`, `#projekt-…`, `#termin-…`, `#approval-…` |
| Q9 | Attachments | Defer Phase 2 |
| Q10 | Edits / deletes | Edit ≤5 min author; soft-delete author or admin |
| Q11 | Threading | Flat |
| Q12 | Search | Thread-scoped LIKE; defer cross-thread |
| Q13 | Schema | Migration 057: 5 new tables + `project_teams.chat_access` |
| Q14 | Retention | Forever, soft-delete only |
| Q15 | Verlauf | No; "Pin to Verlauf" Phase 2 |
| Q16 | Sidebar entry | Both — top-level Chat + per-project tab |
| Q17 | Custom Views | NOT a 5th source |
| Q18 | Bulk email overlap | Distinct surfaces |
| Q19 | Who can chat | Read = visibility; observer read-only; external opt-in via `chat_access` |
| Q20 | External counsel/expert | Default `chat_access=false`; lead toggles per project |
| Q21 | PWA push | Defer Phase 2 |
| Q22 | DM reachability | Scoped to "shares ≥1 visible project" |
| Q23 | DM small-group cap | 8 |
| Q24 | Auto-provision | Lazy on first read |
| Q25 | System-post audience | Same as any chat post (respects `chat_access`) |
| Q26 | Edit window | 5 min |
| Q27 | Markdown subset | Blockquote yes; tables no; strikethrough no |
| Q28 | `@everyone` / `@team` | No in v1 |
| Q29 | Badge count | Messages, capped at 99+ |
| Q30 | Sound | No (opt-in deferred) |
| Q31 | Default landing | Most-recently-used thread |
| Q32 | last_activity bump | Yes on chat thread; no on project record |
---
## §13 Open follow-ups (not for v1)
- **Bot integrations** (e.g. /Frist-Bot for natural-language deadline lookup). Out of scope; if AI chat (`feature-roadmap.md`) ever ships, it lives at `/ask` not `/chat`. Reserve mental separation.
- **External-firm participants** (opposing counsel, expert witnesses outside HLC). Big compliance question; not v1 / not v2 / not yet.
- **Slack / Teams bridging**. Very tempting and very complex (auth, identity mapping, message format translation). Defer until paliad chat usage justifies.
- **Voice messages** (German lawyers love voice notes). Out of scope.
---
## §14 What I need from m to lock
1. **§7.1 adoption sanity-check**: are PAs likely to use this, or is it a half-empty surface?
2. **Q1 — surface set**: confirm per-project + DMs, defer per-deadline / per-termin / partner-unit / topical.
3. **Q4 — SSE**: confirm SSE direction.
4. **Q5 — notification path**: confirm in-app-only v1 (push + email digest deferred).
5. **Q19/Q20 — chat_access flag** on `project_teams`, defaulting OFF for `local_counsel`/`expert`.
6. **Q22Q32 — the 11 follow-up questions** in §6.
7. **§8 phasing** — single PR or 1A+1B split.
If m greenlights with "I agree with all your recommendations - go." (the Q4 of t-139 pattern), I lock the design and the head routes the coder shift.
If m flips any answer, I rev the doc before handover.
**Inventor parks here.** No coder self-load.
---
## §15 Appendix — file/index inventory
For the implementer's reference; verified live 2026-05-07.
**Existing tables touched:**
- `paliad.project_teams` — new column `chat_access`, backfill external roles.
- `paliad.projects` — read-only, source for `path` traversal.
- `paliad.users` — read-only, FK target.
- `paliad.partner_unit_members`, `paliad.project_partner_units` — read-only, derivation predicate.
**New tables:**
- `paliad.chat_threads`
- `paliad.chat_thread_participants`
- `paliad.chat_messages`
- `paliad.chat_reads`
- `paliad.chat_mentions`
**Existing Go services touched:**
- `internal/services/visibility.go` — read-only reuse.
- `internal/services/derivation_service.go` — read-only reuse for partner-unit derivation check.
- `internal/services/approval_service.go` — auto-post hook on `Submit*`.
**New Go services:**
- `internal/services/chat_service.go`
- `internal/services/chat_bus.go` (interface + in-process default)
- `internal/services/markdown.go`
**Existing handlers touched:**
- `internal/handlers/handlers.go` — wire chat routes when `Chat != nil`.
**New handlers:**
- `internal/handlers/chat.go`
- `internal/handlers/chat_stream.go`
**Existing frontend touched:**
- `frontend/src/components/Sidebar.tsx`
- `frontend/src/projects-detail.tsx` (Chat tab)
- `frontend/src/client/sidebar.ts` (badge update)
- `frontend/src/i18n.ts` (~80 new keys)
- `frontend/src/build.ts` (chat bundle)
- `frontend/src/styles/global.css`
**New frontend:**
- `frontend/src/chat.tsx`
- `frontend/src/client/chat.ts`
- `frontend/src/client/markdown.ts` (or shared with views)
— end of design —

View File

@@ -30,12 +30,15 @@ import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderInbox } from "./src/inbox";
import { renderViews } from "./src/views";
import { renderViewsEditor } from "./src/views-editor";
import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
@@ -250,6 +253,8 @@ async function build() {
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
join(import.meta.dir, "src/client/inbox.ts"),
join(import.meta.dir, "src/client/views.ts"),
join(import.meta.dir, "src/client/views-editor.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
@@ -260,6 +265,7 @@ async function build() {
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
@@ -363,6 +369,8 @@ async function build() {
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
await Bun.write(join(DIST, "inbox.html"), renderInbox());
await Bun.write(join(DIST, "views.html"), renderViews());
await Bun.write(join(DIST, "views-editor.html"), renderViewsEditor());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());
@@ -373,6 +381,7 @@ async function build() {
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in

View File

@@ -0,0 +1,66 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminBroadcasts(): 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="admin.broadcasts.title">Broadcasts &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/broadcasts" />
<BottomNav currentPath="/admin/broadcasts" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.broadcasts.heading">Broadcasts</h1>
<p className="tool-subtitle" data-i18n="admin.broadcasts.subtitle">
Versendete Massen-E-Mails an Teamauswahlen.
</p>
</div>
</div>
<div className="entity-table-wrap">
<table className="entity-table entity-table--readonly broadcasts-table">
<thead>
<tr>
<th data-i18n="admin.broadcasts.col.sent_at">Gesendet</th>
<th data-i18n="admin.broadcasts.col.subject">Betreff</th>
<th data-i18n="admin.broadcasts.col.sender">Absender:in</th>
<th data-i18n="admin.broadcasts.col.count">Empf&auml;nger</th>
</tr>
</thead>
<tbody id="broadcasts-tbody">
<tr><td colspan={4} data-i18n="admin.broadcasts.loading">Lade ...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="broadcasts-empty" style="display:none">
<p data-i18n="admin.broadcasts.empty">Noch keine Broadcasts versandt.</p>
</div>
<div id="broadcast-detail" className="hidden" />
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-broadcasts.js"></script>
</body>
</html>
);
}

View File

@@ -83,6 +83,11 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenf&uuml;hren, bef&ouml;rdern.</p>
</a>
<a href="/admin/broadcasts" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>

View File

@@ -0,0 +1,137 @@
// admin-broadcasts.ts — read-only viewer for paliad.email_broadcasts.
//
// global_admin sees every row; senders see only their own. Authority is
// enforced server-side; this client just renders whatever /api/admin/broadcasts
// returns. Click a row → load detail (subject, body, recipient list).
import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface BroadcastRow {
id: string;
subject: string;
sender_id: string;
sender_name: string;
sender_email: string;
recipient_count: number;
sent_at: string;
template_key?: string;
}
interface BroadcastDetailRecipient {
id: string;
email: string;
display_name: string;
}
interface BroadcastDetail extends BroadcastRow {
body: string;
recipient_filter: Record<string, unknown>;
send_report: { total: number; sent: number; failed: number };
recipients: BroadcastDetailRecipient[];
}
let rows: BroadcastRow[] = [];
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
async function load(): Promise<void> {
const tbody = document.getElementById("broadcasts-tbody")!;
const empty = document.getElementById("broadcasts-empty")!;
try {
const res = await fetch("/api/admin/broadcasts");
if (!res.ok) {
if (res.status === 403) {
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.forbidden") || "Zugriff verweigert.")}</td></tr>`;
return;
}
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
return;
}
rows = (await res.json()) as BroadcastRow[];
} catch {
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
return;
}
if (!rows.length) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = rows
.map(
(r) => `
<tr data-broadcast-id="${esc(r.id)}">
<td>${esc(fmtDate(r.sent_at))}</td>
<td>${esc(r.subject)}</td>
<td>${esc(r.sender_name || r.sender_email || "—")}</td>
<td>${r.recipient_count}</td>
</tr>
`,
)
.join("");
tbody.querySelectorAll<HTMLTableRowElement>("tr[data-broadcast-id]").forEach((tr) => {
tr.addEventListener("click", () => loadDetail(tr.dataset.broadcastId!));
tr.style.cursor = "pointer";
});
}
async function loadDetail(id: string): Promise<void> {
const detail = document.getElementById("broadcast-detail")!;
detail.classList.remove("hidden");
detail.innerHTML = `<p>${esc(t("common.loading") || "Lade…")}</p>`;
try {
const res = await fetch(`/api/admin/broadcasts/${encodeURIComponent(id)}`);
if (!res.ok) {
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
return;
}
const d = (await res.json()) as BroadcastDetail;
const recList = (d.recipients || [])
.map(
(r) =>
`<li>${esc(r.display_name || "—")} <span class="broadcast-recip-email">&lt;${esc(r.email)}&gt;</span></li>`,
)
.join("");
const report = d.send_report || { total: d.recipient_count, sent: d.recipient_count, failed: 0 };
detail.innerHTML = `
<article class="card broadcast-detail-card">
<header>
<h2>${esc(d.subject)}</h2>
<p class="muted">
${esc(t("admin.broadcasts.detail.sent_by") || "Gesendet von")} <strong>${esc(d.sender_name || d.sender_email)}</strong>
${esc(fmtDate(d.sent_at))}
${report.sent}/${report.total} ${esc(t("admin.broadcasts.detail.delivered") || "versandt")}
${report.failed > 0 ? `${report.failed} ${esc(t("admin.broadcasts.detail.failed") || "fehlgeschlagen")}` : ""}
</p>
</header>
<div class="broadcast-detail-body">${esc(d.body)}</div>
<section class="broadcast-detail-recipients">
<h3>${esc(t("admin.broadcasts.detail.recipients") || "Empfänger")} (${d.recipients?.length ?? 0})</h3>
<ul>${recList}</ul>
</section>
</article>
`;
detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch {
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(() => load());
load();
});

View File

@@ -1,4 +1,4 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface PartnerUnit {
@@ -16,8 +16,11 @@ interface Member {
display_name: string;
office: string;
job_title: string | null;
unit_role: string;
}
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
interface PartnerUnitWithMembers extends PartnerUnit {
lead_display_name?: string;
lead_email?: string;
@@ -284,16 +287,54 @@ function renderMemberList(): void {
return;
}
list.innerHTML = u.members
.map(
(m) => `<li class="partner-unit-member-item">
.map((m) => {
const roleOptions = UNIT_ROLES.map((r) => {
const label = tDyn(`unit_role.${r}`) || r;
const sel = m.unit_role === r ? " selected" : "";
return `<option value="${esc(r)}"${sel}>${esc(label)}</option>`;
}).join("");
return `<li class="partner-unit-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</li>`,
)
<span class="partner-unit-member-actions">
<select class="pu-role-select" data-user="${esc(m.user_id)}" aria-label="${escAttr(tDyn("admin.partner_units.member.role") || "Rolle")}">${roleOptions}</select>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</span>
</li>`;
})
.join("");
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
b.addEventListener("click", () => removeMember(b.dataset.user!)),
);
list.querySelectorAll<HTMLSelectElement>(".pu-role-select").forEach((s) =>
s.addEventListener("change", () => setMemberRole(s.dataset.user!, s.value, s)),
);
}
async function setMemberRole(userID: string, role: string, sel: HTMLSelectElement): Promise<void> {
if (!activeUnitID) return;
// Snapshot the prior selection so we can roll back on failure.
const u = units.find((x) => x.id === activeUnitID);
const prior = u?.members.find((m) => m.user_id === userID)?.unit_role;
sel.disabled = true;
const resp = await fetch(
`/api/partner-units/${activeUnitID}/members/${userID}/role`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unit_role: role }),
},
);
sel.disabled = false;
if (!resp.ok) {
if (prior !== undefined) sel.value = prior;
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Rolle konnte nicht gespeichert werden.", true);
return;
}
await loadUnits();
renderMemberList();
render();
showFeedback(tDyn("admin.partner_units.feedback.role_updated") || "Rolle aktualisiert.", false);
}
function wireSuggestions(): void {

View File

@@ -0,0 +1,283 @@
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
//
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
// collects subject + body + (optional) template and posts to
// /api/team/broadcast. On success it shows a per-recipient send report
// and closes.
//
// Per-recipient privacy: each member receives their own envelope. The
// modal lists every addressee so the sender knows exactly who will be
// mailed; there is no surprise to-line.
import { t } from "./i18n";
export interface BroadcastRecipient {
user_id: string;
email: string;
display_name: string;
first_name: string;
role_on_project: string;
}
export interface OpenBroadcastModalArgs {
recipients: BroadcastRecipient[];
projectID?: string | null;
projectIDs?: string[];
offices?: string[];
roles?: string[];
}
interface EmailTemplateOption {
key: string;
subject: string;
body: string;
is_default: boolean;
}
const RECIPIENT_CAP = 100;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// firstName extracts the first whitespace-separated token from a display
// name. "Anna von Beispiel" → "Anna". Empty input → "".
export function firstName(displayName: string): string {
return displayName.trim().split(/\s+/)[0] ?? "";
}
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
if (!args.recipients.length) {
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
return;
}
if (args.recipients.length > RECIPIENT_CAP) {
alert(
(t("team.broadcast.error.too_many") || "Empfängerlimit ({cap}) überschritten.").replace(
"{cap}",
String(RECIPIENT_CAP),
),
);
return;
}
// Existing modal? Remove. Avoids stacking on rapid double-click.
document.getElementById("broadcast-modal")?.remove();
const overlay = document.createElement("div");
overlay.id = "broadcast-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args);
document.body.appendChild(overlay);
// Close handlers
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener("keydown", function escClose(e) {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escClose);
}
});
// Recipient toggle
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
// Submit
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await onSubmit(form, overlay, args);
});
}
function renderShell(args: OpenBroadcastModalArgs): string {
const count = args.recipients.length;
const previewItems = args.recipients
.slice(0, 5)
.map((r) => esc(r.display_name) + " &lt;" + esc(r.email) + "&gt;")
.join(", ");
const more = count > 5 ? ` +${count - 5}` : "";
const fullList = args.recipients
.map(
(r) =>
`<li><span class="broadcast-recip-name">${esc(r.display_name)}</span> <span class="broadcast-recip-email">&lt;${esc(r.email)}&gt;</span>${
r.role_on_project ? ` <span class="broadcast-recip-role">${esc(r.role_on_project)}</span>` : ""
}</li>`,
)
.join("");
return `
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
<header class="modal-header">
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">&times;</button>
</header>
<form data-broadcast-form>
<div class="modal-body">
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
</footer>
</form>
</div>
`;
}
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
errEl?.classList.add("hidden");
okEl?.classList.add("hidden");
if (!subject) {
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
return;
}
if (!body) {
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
return;
}
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
}
const recipientFilter: Record<string, unknown> = {};
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
if (args.projectID) recipientFilter.project_id = args.projectID;
if (args.offices?.length) recipientFilter.offices = args.offices;
if (args.roles?.length) recipientFilter.roles = args.roles;
const lang = (document.documentElement.lang === "en" ? "en" : "de");
try {
const res = await fetch("/api/team/broadcast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_id: args.projectID ?? null,
subject,
body,
template_key: templateKey || undefined,
lang,
recipient_filter: recipientFilter,
recipients: args.recipients,
}),
});
if (!res.ok) {
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
showError(errEl, (errBody as { error?: string }).error || "Send failed");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
return;
}
const report = (await res.json()) as { sent: number; failed: number; total: number };
if (okEl) {
okEl.classList.remove("hidden");
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
okEl.textContent = tpl
.replace("{sent}", String(report.sent))
.replace("{total}", String(report.total))
.replace("{failed}", String(report.failed));
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
}
setTimeout(() => overlay.remove(), 2500);
} catch (e) {
showError(errEl, String(e));
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
}
}
function showError(el: HTMLDivElement | null | undefined, msg: string) {
if (!el) return;
el.textContent = msg;
el.classList.remove("hidden");
}
// stripGoTemplate is best-effort: existing email templates carry
// `{{define "content"}}` wrappers and Go-template branches the broadcast
// compose form can't honour. The bulk-send pipeline expects plain
// Markdown + the placeholder set documented in the modal, so we strip
// the template directives before populating the textarea. Senders can
// still edit further.
function stripGoTemplate(src: string): string {
return src
.replace(/\{\{\s*(define|end|block|if|else|range|with)\b[^}]*\}\}/g, "")
.trim();
}

View File

@@ -649,6 +649,40 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.later": "Sp\u00e4ter",
"deadlines.complete.action": "Erledigen",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Inkl. Unterprojekte",
"aggregation.toggle.direct_only": "Nur direkt",
"aggregation.attribution.on": "auf",
// t-paliad-139 Phase 2 \u2014 Team-tab subsections + Partner Units management
"projects.team.section.from_descendants": "Aus Unterprojekten",
"projects.team.section.from_descendants.hint": "Personen, die direkt auf einem Unterprojekt eingetragen sind und nicht auf diesem oder einem \u00dcbergeordneten.",
"projects.team.section.derived": "Abgeleitet (Partner Unit)",
"projects.team.section.derived.hint": "Mitglieder, die \u00fcber eine zugeordnete Partner Unit auf diesem Projekt aktiv sind.",
"projects.team.section.units": "Partner Units",
"projects.team.section.units.hint": "Partner Units, die auf diesem Projekt eingebunden sind. Mitglieder mit passenden Unit-Rollen werden automatisch abgeleitet.",
"projects.team.derived.from": "\u00fcber",
"projects.team.derived.visibility": "Sicht",
"projects.team.derived.authority": "Sicht & 4-Augen",
"projects.team.derived.authority.hint": "Stimmrecht: Abgeleitete Mitglieder z\u00e4hlen als Approver.",
"projects.team.units.attach": "Partner Unit zuordnen",
"projects.team.units.detach": "Entfernen",
"projects.team.units.choose": "Bitte Unit w\u00e4hlen\u2026",
"projects.team.units.select": "Unit",
"projects.team.units.derive_roles": "Welche Unit-Rollen ableiten?",
"projects.team.units.grants_authority": "Stimmrecht abgeben (4-Augen)",
"projects.team.units.col.name": "Unit",
"projects.team.units.col.derive_roles": "Abgeleitete Rollen",
"projects.team.units.col.authority": "Authority",
"projects.team.units.members": "Mitglieder",
"projects.team.units.empty": "Keine Partner Units zugeordnet.",
"projects.team.units.confirm_detach": "Partner Unit entfernen?",
"unit_role.lead": "Lead",
"unit_role.attorney": "Attorney",
"unit_role.senior_pa": "Senior PA",
"unit_role.pa": "PA",
"unit_role.paralegal": "Paralegal",
"deadlines.neu.title": "Neue Frist \u2014 Paliad",
"deadlines.neu.heading": "Neue Frist anlegen",
"deadlines.neu.subtitle": "Eine persistente Frist an einer Akte. Sichtbar f\u00fcr alle Personen, die die Akte sehen k\u00f6nnen.",
@@ -1116,6 +1150,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.confirm_remove": "Mitglied entfernen?",
"projects.detail.team.empty": "Noch keine Teammitglieder.",
"projects.detail.team.error.user_required": "Benutzer ausw\u00e4hlen",
"projects.detail.team.invite.hint": "Benutzer nicht gefunden?",
"projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.",
"projects.detail.team.invite.cta": "Einladen",
"projects.view.tree": "Baumansicht",
"projects.tree.toggle": "Aufklappen / Zuklappen",
"projects.tree.loading": "Baum wird geladen\u2026",
@@ -1364,6 +1401,52 @@ const translations: Record<Lang, Record<string, string>> = {
"team.dept.lead": "Lead",
"team.dept.unassigned": "Ohne Partner Unit",
"team.partner_unit.unassigned": "Ohne Partner Unit",
// Project filter (t-paliad-147)
"team.filter.project": "Projekt",
"team.filter.project.all": "Alle Projekte",
"team.filter.project.selected": "ausgewählt",
"team.filter.project.clear": "Alle abwählen",
// Broadcast modal (t-paliad-147)
"team.broadcast.button": "E-Mail an Auswahl",
"team.broadcast.title": "E-Mail an Auswahl",
"team.broadcast.recipients": "Empfänger",
"team.broadcast.show_all": "Alle anzeigen",
"team.broadcast.template": "Vorlage",
"team.broadcast.template_optional": "optional",
"team.broadcast.template_freeform": "Freitext",
"team.broadcast.template.invitation": "Einladung",
"team.broadcast.template.deadline_digest": "Frist-Digest",
"team.broadcast.subject": "Betreff",
"team.broadcast.body": "Nachricht",
"team.broadcast.body_placeholder": "Hallo {{first_name}}, …",
"team.broadcast.placeholders_hint": "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}",
"team.broadcast.markdown_hint": "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.",
"team.broadcast.send": "Senden",
"team.broadcast.sending": "Sende…",
"team.broadcast.sent": "Versandt",
"team.broadcast.success": "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).",
"team.broadcast.error.no_recipients": "Keine Empfänger ausgewählt.",
"team.broadcast.error.too_many": "Empfängerlimit ({cap}) überschritten.",
"team.broadcast.error.subject_required": "Betreff ist erforderlich.",
"team.broadcast.error.body_required": "Nachricht ist erforderlich.",
"common.close": "Schließen",
// Admin broadcasts viewer (t-paliad-147)
"admin.broadcasts.title": "Broadcasts — Paliad",
"admin.broadcasts.heading": "Broadcasts",
"admin.broadcasts.subtitle": "Versendete Massen-E-Mails an Teamauswahlen.",
"admin.broadcasts.col.sent_at": "Gesendet",
"admin.broadcasts.col.subject": "Betreff",
"admin.broadcasts.col.sender": "Absender:in",
"admin.broadcasts.col.count": "Empfänger",
"admin.broadcasts.loading": "Lade…",
"admin.broadcasts.empty": "Noch keine Broadcasts versandt.",
"admin.broadcasts.detail.sent_by": "Gesendet von",
"admin.broadcasts.detail.delivered": "versandt",
"admin.broadcasts.detail.failed": "fehlgeschlagen",
"admin.broadcasts.detail.recipients": "Empfänger",
"common.forbidden": "Zugriff verweigert.",
"common.load_error": "Fehler beim Laden.",
"common.loading": "Lade…",
"partner_unit.heading": "Meine Partner Units",
"partner_unit.subtitle": "Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.",
"partner_unit.none": "Sie sind noch keiner Partner Unit zugeordnet.",
@@ -1389,6 +1472,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.",
"admin.card.feature_flags.title": "Feature-Flags",
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.",
"admin.email_templates.title": "Email-Templates — Paliad",
"admin.email_templates.heading": "Email-Templates",
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
@@ -1507,6 +1592,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.feedback.created": "Angelegt.",
"admin.partner_units.feedback.updated": "Aktualisiert.",
"admin.partner_units.feedback.deleted": "Gelöscht.",
"admin.partner_units.feedback.role_updated": "Rolle aktualisiert.",
"admin.partner_units.member.heading": "Mitglieder verwalten",
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
"admin.partner_units.member.add": "Mitglied hinzufügen",
@@ -1514,6 +1600,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.member.remove": "Entfernen",
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
"admin.partner_units.member.placeholder": "Name oder E-Mail",
"admin.partner_units.member.role": "Rolle",
"admin.audit.loading": "Lade…",
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
"admin.audit.loadmore": "Weitere laden",
@@ -1680,6 +1767,7 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.decided_by": "Entschieden von",
"approvals.decision_kind.peer": "Genehmigt durch Teammitglied",
"approvals.decision_kind.admin_override": "Admin-Sign-off",
"approvals.decision_kind.derived_peer": "Genehmigt durch abgeleitetes Mitglied (Partner Unit)",
"approvals.error.self_approval": "Eigengenehmigung nicht zulässig.",
"approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.",
"approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.",
@@ -1699,6 +1787,100 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.policies.no_approval": "Keine Genehmigung erforderlich",
"approvals.policies.copy_parent": "Aus Eltern-Projekt übernehmen",
"approvals.policies.set_all_associate": "Alle auf Associate setzen",
// t-paliad-144 — Custom Views
"nav.group.user_views": "Meine Sichten",
"nav.user_views.new": "Neue Sicht",
"views.title": "Sichten — Paliad",
"views.heading": "Sichten",
"views.subtitle": "Eigene Sichten über Ihre Daten — Filter und Darstellung speicherbar.",
"views.loading": "Lädt …",
"views.shape.list": "Liste",
"views.shape.cards": "Karten",
"views.shape.calendar": "Kalender",
"views.save_as": "Als Sicht speichern",
"views.action.edit": "Bearbeiten",
"views.empty.title": "Keine Einträge gefunden.",
"views.error.back": "Zurück zur Sichten-Übersicht",
"views.error.not_found": "Sicht nicht gefunden.",
"views.error.network": "Netzwerkfehler — bitte erneut versuchen.",
"views.toast.inaccessible_one": "1 Projekt in dieser Sicht ist nicht mehr sichtbar.",
"views.toast.inaccessible_n": "{n} Projekte in dieser Sicht sind nicht mehr sichtbar.",
"views.calendar.mobile_fallback": "Kalender-Ansicht ist auf grossen Bildschirmen am besten.",
"views.onboarding.title": "Eigene Sichten — was ist das?",
"views.onboarding.body": "Eine Sicht ist eine gespeicherte Filterkombination — z.B. „Fristen meiner Projekte in den nächsten 14 Tagen“. Sichten erscheinen als eigene Buttons in der Sidebar.",
"views.onboarding.create": "Beispiel-Sicht erstellen",
"views.source.deadline": "Fristen",
"views.source.appointment": "Termine",
"views.source.project_event": "Projekt-Verlauf",
"views.source.approval_request": "Genehmigungen",
"views.kind.deadline": "Frist",
"views.kind.appointment": "Termin",
"views.kind.project_event": "Verlauf",
"views.kind.approval_request": "Genehmigung",
"views.scope.all_visible": "Alle sichtbaren",
"views.scope.my_subtree": "Mein Teilbaum",
"views.scope.explicit": "Bestimmte Projekte",
"views.scope.personal_only": "Nur persönliche",
"views.horizon.next_7d": "Nächste 7 Tage",
"views.horizon.next_30d": "Nächste 30 Tage",
"views.horizon.next_90d": "Nächste 90 Tage",
"views.horizon.past_30d": "Letzte 30 Tage",
"views.horizon.past_90d": "Letzte 90 Tage",
"views.horizon.any": "Beliebig",
"views.horizon.all": "Komplett (alle Daten)",
"views.horizon.custom": "Benutzerdefiniert",
"views.density.comfortable": "Bequem",
"views.density.compact": "Kompakt",
"views.col.date": "Datum",
"views.col.time": "Wann",
"views.col.title": "Titel",
"views.col.project": "Projekt",
"views.col.actor": "Akteur",
"views.col.status": "Status",
"views.col.rule": "Regel",
"views.col.event_type": "Typ",
"views.col.location": "Ort",
"views.col.appointment_type": "Termin-Typ",
"views.col.approval_status": "Genehmigung",
"views.col.decided_by": "Entschieden von",
"views.col.kind": "Art",
"views.editor.title": "Sicht bearbeiten — Paliad",
"views.editor.heading.new": "Neue Sicht",
"views.editor.heading.edit": "Sicht bearbeiten",
"views.editor.subtitle": "Wählen Sie Quellen, Filter und Darstellung. Änderungen speichern Sie unten.",
"views.editor.section.identity": "Bezeichnung",
"views.editor.section.sources": "Quellen",
"views.editor.section.scope": "Geltungsbereich",
"views.editor.section.time": "Zeitraum",
"views.editor.section.render": "Darstellung",
"views.editor.field.name": "Name",
"views.editor.field.slug": "Slug (URL)",
"views.editor.field.icon": "Icon",
"views.editor.field.show_count": "Treffer-Anzahl in der Sidebar anzeigen",
"views.editor.field.scope_mode": "Projekte",
"views.editor.field.personal_only": "Nur persönliche",
"views.editor.field.horizon": "Horizont",
"views.editor.field.shape": "Form",
"views.editor.field.density": "Dichte",
"views.editor.hint.slug": "Kleinbuchstaben, Ziffern und Bindestriche — nicht reservierte Wörter.",
"views.editor.hint.sources": "Welche Datenarten zeigt diese Sicht?",
"views.editor.icon.default": "Standard (Ordner)",
"views.editor.icon.clock": "Uhr",
"views.editor.icon.calendar": "Kalender",
"views.editor.icon.bell": "Glocke",
"views.editor.icon.folder": "Ordner",
"views.editor.icon.users": "Personen",
"views.editor.icon.building": "Gebäude",
"views.editor.save": "Speichern",
"views.editor.cancel": "Abbrechen",
"views.editor.delete": "Löschen",
"views.editor.confirm_delete": "Diese Sicht wirklich löschen?",
"views.editor.error.name_required": "Name ist erforderlich.",
"views.editor.error.slug_format": "Slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten und muss mit einem Buchstaben oder einer Ziffer beginnen.",
"views.editor.error.sources_required": "Mindestens eine Quelle wählen.",
"views.editor.error.load_failed": "Sicht konnte nicht geladen werden.",
"views.editor.error.delete_failed": "Sicht konnte nicht gelöscht werden.",
},
en: {
@@ -2331,6 +2513,40 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.later": "Later",
"deadlines.complete.action": "Complete",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Incl. sub-projects",
"aggregation.toggle.direct_only": "Direct only",
"aggregation.attribution.on": "on",
// t-paliad-139 Phase 2 \u2014 Team-tab subsections + Partner Units management
"projects.team.section.from_descendants": "From sub-projects",
"projects.team.section.from_descendants.hint": "People directly staffed on a sub-project who are not on this project or an ancestor.",
"projects.team.section.derived": "Derived (Partner Unit)",
"projects.team.section.derived.hint": "Members active on this project via an attached partner unit.",
"projects.team.section.units": "Partner Units",
"projects.team.section.units.hint": "Partner units attached to this project. Members in the listed unit roles auto-derive.",
"projects.team.derived.from": "via",
"projects.team.derived.visibility": "View",
"projects.team.derived.authority": "View & 4-eye",
"projects.team.derived.authority.hint": "Authority: derived members count as approvers.",
"projects.team.units.attach": "Attach partner unit",
"projects.team.units.detach": "Remove",
"projects.team.units.choose": "Select a unit\u2026",
"projects.team.units.select": "Unit",
"projects.team.units.derive_roles": "Which unit roles should derive?",
"projects.team.units.grants_authority": "Grant authority (4-eye)",
"projects.team.units.col.name": "Unit",
"projects.team.units.col.derive_roles": "Derived roles",
"projects.team.units.col.authority": "Authority",
"projects.team.units.members": "members",
"projects.team.units.empty": "No partner units attached.",
"projects.team.units.confirm_detach": "Remove partner unit?",
"unit_role.lead": "Lead",
"unit_role.attorney": "Attorney",
"unit_role.senior_pa": "Senior PA",
"unit_role.pa": "PA",
"unit_role.paralegal": "Paralegal",
"deadlines.neu.title": "New deadline \u2014 Paliad",
"deadlines.neu.heading": "Create new deadline",
"deadlines.neu.subtitle": "A persistent deadline attached to a matter. Visible to anyone who can see that matter.",
@@ -2792,6 +3008,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.confirm_remove": "Remove member?",
"projects.detail.team.empty": "No team members yet.",
"projects.detail.team.error.user_required": "Select a user",
"projects.detail.team.invite.hint": "User not found?",
"projects.detail.team.invite.hint_email": "No one with that email.",
"projects.detail.team.invite.cta": "Invite",
"projects.view.tree": "Tree view",
"projects.tree.toggle": "Expand / collapse",
"projects.tree.loading": "Loading tree…",
@@ -3037,6 +3256,52 @@ const translations: Record<Lang, Record<string, string>> = {
"team.dept.lead": "Lead",
"team.dept.unassigned": "No partner unit",
"team.partner_unit.unassigned": "No partner unit",
// Project filter (t-paliad-147)
"team.filter.project": "Project",
"team.filter.project.all": "All projects",
"team.filter.project.selected": "selected",
"team.filter.project.clear": "Deselect all",
// Broadcast modal (t-paliad-147)
"team.broadcast.button": "Email selection",
"team.broadcast.title": "Email selection",
"team.broadcast.recipients": "Recipients",
"team.broadcast.show_all": "Show all",
"team.broadcast.template": "Template",
"team.broadcast.template_optional": "optional",
"team.broadcast.template_freeform": "Free-form",
"team.broadcast.template.invitation": "Invitation",
"team.broadcast.template.deadline_digest": "Deadline digest",
"team.broadcast.subject": "Subject",
"team.broadcast.body": "Message",
"team.broadcast.body_placeholder": "Hi {{first_name}}, …",
"team.broadcast.placeholders_hint": "Placeholders: {{name}}, {{first_name}}, {{role_on_project}}",
"team.broadcast.markdown_hint": "Markdown supported: **bold**, *italic*, [link](https://...), - bullet.",
"team.broadcast.send": "Send",
"team.broadcast.sending": "Sending…",
"team.broadcast.sent": "Sent",
"team.broadcast.success": "{sent} of {total} emails sent ({failed} failed).",
"team.broadcast.error.no_recipients": "No recipients selected.",
"team.broadcast.error.too_many": "Recipient limit ({cap}) exceeded.",
"team.broadcast.error.subject_required": "Subject is required.",
"team.broadcast.error.body_required": "Message is required.",
"common.close": "Close",
// Admin broadcasts viewer (t-paliad-147)
"admin.broadcasts.title": "Broadcasts — Paliad",
"admin.broadcasts.heading": "Broadcasts",
"admin.broadcasts.subtitle": "Sent bulk emails to team selections.",
"admin.broadcasts.col.sent_at": "Sent",
"admin.broadcasts.col.subject": "Subject",
"admin.broadcasts.col.sender": "Sender",
"admin.broadcasts.col.count": "Recipients",
"admin.broadcasts.loading": "Loading…",
"admin.broadcasts.empty": "No broadcasts sent yet.",
"admin.broadcasts.detail.sent_by": "Sent by",
"admin.broadcasts.detail.delivered": "delivered",
"admin.broadcasts.detail.failed": "failed",
"admin.broadcasts.detail.recipients": "Recipients",
"common.forbidden": "Access denied.",
"common.load_error": "Load error.",
"common.loading": "Loading…",
"partner_unit.heading": "My Partner Units",
"partner_unit.subtitle": "Partner Units are structural units — separate from project teams. Membership is admin-managed.",
"partner_unit.none": "You are not a member of any Partner Unit yet.",
@@ -3062,6 +3327,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.",
"admin.card.feature_flags.title": "Feature Flags",
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.",
"admin.email_templates.title": "Email Templates — Paliad",
"admin.email_templates.heading": "Email Templates",
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",
@@ -3180,6 +3447,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.feedback.created": "Created.",
"admin.partner_units.feedback.updated": "Updated.",
"admin.partner_units.feedback.deleted": "Deleted.",
"admin.partner_units.feedback.role_updated": "Role updated.",
"admin.partner_units.member.heading": "Manage members",
"admin.partner_units.member.empty": "No members yet.",
"admin.partner_units.member.add": "Add member",
@@ -3187,6 +3455,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.member.remove": "Remove",
"admin.partner_units.member.confirm_remove": "Remove member?",
"admin.partner_units.member.placeholder": "Name or email",
"admin.partner_units.member.role": "Role",
"admin.audit.loading": "Loading…",
"admin.audit.empty": "No events match the selected filters.",
"admin.audit.loadmore": "Load more",
@@ -3353,6 +3622,7 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.decided_by": "Decided by",
"approvals.decision_kind.peer": "Peer approval",
"approvals.decision_kind.admin_override": "Admin override",
"approvals.decision_kind.derived_peer": "Approved by derived member (Partner Unit)",
"approvals.error.self_approval": "You cannot approve your own request.",
"approvals.error.not_authorized": "You don't have the required role.",
"approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.",
@@ -3372,6 +3642,100 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.policies.no_approval": "No approval needed",
"approvals.policies.copy_parent": "Copy from parent project",
"approvals.policies.set_all_associate": "Set all to Associate",
// t-paliad-144 — Custom Views
"nav.group.user_views": "My Views",
"nav.user_views.new": "New view",
"views.title": "Views — Paliad",
"views.heading": "Views",
"views.subtitle": "Saved views over your data — filters and shape preserved.",
"views.loading": "Loading …",
"views.shape.list": "List",
"views.shape.cards": "Cards",
"views.shape.calendar": "Calendar",
"views.save_as": "Save as view",
"views.action.edit": "Edit",
"views.empty.title": "No matches found.",
"views.error.back": "Back to views",
"views.error.not_found": "View not found.",
"views.error.network": "Network error — please retry.",
"views.toast.inaccessible_one": "1 project in this view is no longer visible to you.",
"views.toast.inaccessible_n": "{n} projects in this view are no longer visible to you.",
"views.calendar.mobile_fallback": "Calendar view works best on a wide screen.",
"views.onboarding.title": "Saved views — what are they?",
"views.onboarding.body": "A view is a saved filter combination — e.g. \"Deadlines in my projects in the next 14 days\". Views appear as their own buttons in the sidebar.",
"views.onboarding.create": "Create example view",
"views.source.deadline": "Deadlines",
"views.source.appointment": "Appointments",
"views.source.project_event": "Project history",
"views.source.approval_request": "Approvals",
"views.kind.deadline": "Deadline",
"views.kind.appointment": "Appointment",
"views.kind.project_event": "History",
"views.kind.approval_request": "Approval",
"views.scope.all_visible": "All visible",
"views.scope.my_subtree": "My subtree",
"views.scope.explicit": "Specific projects",
"views.scope.personal_only": "Personal only",
"views.horizon.next_7d": "Next 7 days",
"views.horizon.next_30d": "Next 30 days",
"views.horizon.next_90d": "Next 90 days",
"views.horizon.past_30d": "Last 30 days",
"views.horizon.past_90d": "Last 90 days",
"views.horizon.any": "Any",
"views.horizon.all": "All-time",
"views.horizon.custom": "Custom",
"views.density.comfortable": "Comfortable",
"views.density.compact": "Compact",
"views.col.date": "Date",
"views.col.time": "When",
"views.col.title": "Title",
"views.col.project": "Project",
"views.col.actor": "Actor",
"views.col.status": "Status",
"views.col.rule": "Rule",
"views.col.event_type": "Type",
"views.col.location": "Location",
"views.col.appointment_type": "Appointment type",
"views.col.approval_status": "Approval",
"views.col.decided_by": "Decided by",
"views.col.kind": "Kind",
"views.editor.title": "Edit view — Paliad",
"views.editor.heading.new": "New view",
"views.editor.heading.edit": "Edit view",
"views.editor.subtitle": "Pick sources, filters, and shape. Save to confirm.",
"views.editor.section.identity": "Identity",
"views.editor.section.sources": "Sources",
"views.editor.section.scope": "Scope",
"views.editor.section.time": "Time",
"views.editor.section.render": "Display",
"views.editor.field.name": "Name",
"views.editor.field.slug": "Slug (URL)",
"views.editor.field.icon": "Icon",
"views.editor.field.show_count": "Show count badge in sidebar",
"views.editor.field.scope_mode": "Projects",
"views.editor.field.personal_only": "Personal only",
"views.editor.field.horizon": "Horizon",
"views.editor.field.shape": "Shape",
"views.editor.field.density": "Density",
"views.editor.hint.slug": "Lowercase letters, digits, hyphens — no reserved words.",
"views.editor.hint.sources": "Which data sources should this view include?",
"views.editor.icon.default": "Default (folder)",
"views.editor.icon.clock": "Clock",
"views.editor.icon.calendar": "Calendar",
"views.editor.icon.bell": "Bell",
"views.editor.icon.folder": "Folder",
"views.editor.icon.users": "People",
"views.editor.icon.building": "Building",
"views.editor.save": "Save",
"views.editor.cancel": "Cancel",
"views.editor.delete": "Delete",
"views.editor.confirm_delete": "Delete this view permanently?",
"views.editor.error.name_required": "Name is required.",
"views.editor.error.slug_format": "Slug must be lowercase, start with a letter or digit, contain only letters, digits, and hyphens.",
"views.editor.error.sources_required": "Pick at least one source.",
"views.editor.error.load_failed": "Could not load this view.",
"views.editor.error.delete_failed": "Could not delete this view.",
},
};

View File

@@ -1,4 +1,5 @@
import { initI18n, t, getLang, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
// reject / revoke), and a small inline diff for update / complete / delete
@@ -40,6 +41,7 @@ type Tab = "pending-mine" | "mine";
let currentTab: Tab = "pending-mine";
initI18n();
initSidebar();
document.addEventListener("DOMContentLoaded", () => {
const url = new URL(window.location.href);

View File

@@ -46,6 +46,35 @@ interface ProjectTeamMember {
inherited_from_title?: string | null;
}
// t-paliad-139 — derived team member from a partner-unit attachment.
// One DerivedMember per user; users in multiple attached units carry one
// DerivedMembership per (unit, role) pair so the Herkunft column can list
// every source (t-paliad-143).
interface DerivedMembership {
unit_id: string;
unit_name: string;
unit_role: string;
}
interface DerivedMember {
user_id: string;
user_email: string;
user_display_name: string;
user_office: string;
memberships: DerivedMembership[];
derive_grants_authority: boolean;
}
// t-paliad-139 — partner unit attached to this project.
interface AttachedUnit {
project_id: string;
partner_unit_id: string;
unit_name: string;
derive_unit_roles: string[];
derive_grants_authority: boolean;
derived_member_count: number;
}
interface ProjectMini {
id: string;
type: string;
@@ -71,6 +100,10 @@ interface ProjectEvent {
created_at: string;
created_by?: string;
metadata?: Record<string, unknown>;
// Populated only when the response was joined to paliad.projects (Verlauf
// subtree-aggregating queries on /projects/{id}, t-paliad-139). Used to
// render the attribution chip when the event lives on a descendant.
project_title?: string;
}
interface Deadline {
@@ -81,6 +114,10 @@ interface Deadline {
status: string;
rule_id?: string;
rule_code?: string;
// Populated by the union endpoint (/api/events) which is what the project
// detail page calls — used for attribution when the row lives on a
// descendant project (t-paliad-139).
project_title?: string;
}
interface Appointment {
@@ -91,6 +128,7 @@ interface Appointment {
end_at?: string;
location?: string;
appointment_type?: string;
project_title?: string;
}
interface Me {
@@ -161,12 +199,46 @@ let appointments: Appointment[] = [];
let ancestors: ProjectMini[] = [];
let children: ProjectMini[] = [];
let teamMembers: ProjectTeamMember[] = [];
// t-paliad-139 — additional Team-tab sections.
let descendantStaffed: ProjectTeamMember[] = [];
let derivedMembers: DerivedMember[] = [];
let attachedUnits: AttachedUnit[] = [];
let allUnits: { id: string; name: string; office: string }[] = [];
let userOptions: { id: string; display_name: string; email: string }[] = [];
const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
// Verlauf show rows from this project AND all descendant projects with an
// attribution chip per non-direct row. URL param `?subtree=false` flips to
// narrow (this project's own rows only).
let subtreeMode: boolean = true;
function parseSubtreeMode(): boolean {
try {
const raw = new URLSearchParams(window.location.search).get("subtree");
return raw !== "false";
} catch {
return true;
}
}
function persistSubtreeMode() {
try {
const url = new URL(window.location.href);
if (subtreeMode) {
url.searchParams.delete("subtree");
} else {
url.searchParams.set("subtree", "false");
}
window.history.replaceState({}, "", url.toString());
} catch {
// ignore
}
}
function parseProjectID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "projects" || !parts[1]) return null;
@@ -211,9 +283,18 @@ async function loadParties(id: string) {
}
}
// Build a query string suffix conveying the current subtree mode. The
// backend defaults to subtree (direct_only=false), so we only emit the
// param when the user has flipped to direct.
function subtreeParam(): string {
return subtreeMode ? "" : "&direct_only=true";
}
async function loadEvents(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}`);
const resp = await fetch(
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
);
if (resp.ok) {
events = (await resp.json()) ?? [];
eventsHasMore = events.length === EVENTS_PAGE_SIZE;
@@ -238,7 +319,7 @@ async function loadMoreEvents(id: string) {
}
try {
const resp = await fetch(
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}`,
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
);
if (resp.ok) {
const page: ProjectEvent[] = await resp.json();
@@ -257,10 +338,50 @@ async function loadMoreEvents(id: string) {
}
}
// Shape returned by /api/events — matches EventListItem in
// frontend/src/client/events.ts. Only the fields projects-detail needs.
interface UnionEvent {
type: "deadline" | "appointment";
id: string;
title: string;
project_id?: string;
project_title?: string;
due_date?: string;
status?: string;
rule_id?: string;
rule_code?: string;
start_at?: string;
end_at?: string;
location?: string;
appointment_type?: string;
}
async function loadDeadlines(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/deadlines`);
if (resp.ok) deadlines = (await resp.json()) ?? [];
// t-paliad-139: switched from /api/projects/{id}/deadlines (legacy
// narrow path) to the union endpoint, which already aggregates
// descendants and enriches each row with project_title for the
// attribution chip.
const resp = await fetch(
`/api/events?type=deadline&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
);
if (resp.ok) {
const items: UnionEvent[] = (await resp.json()) ?? [];
deadlines = items
.filter((it) => it.type === "deadline")
.map((it) => ({
id: it.id,
project_id: it.project_id ?? "",
title: it.title,
due_date: it.due_date ?? "",
status: it.status ?? "pending",
rule_id: it.rule_id,
rule_code: it.rule_code,
project_title: it.project_title,
}));
} else {
deadlines = [];
}
} catch {
deadlines = [];
}
@@ -268,8 +389,27 @@ async function loadDeadlines(id: string) {
async function loadAppointments(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/appointments`);
if (resp.ok) appointments = (await resp.json()) ?? [];
// t-paliad-139: same migration as loadDeadlines.
const resp = await fetch(
`/api/events?type=appointment&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
);
if (resp.ok) {
const items: UnionEvent[] = (await resp.json()) ?? [];
appointments = items
.filter((it) => it.type === "appointment")
.map((it) => ({
id: it.id,
project_id: it.project_id,
title: it.title,
start_at: it.start_at ?? "",
end_at: it.end_at,
location: it.location,
appointment_type: it.appointment_type,
project_title: it.project_title,
}));
} else {
appointments = [];
}
} catch {
appointments = [];
}
@@ -310,7 +450,7 @@ function renderAppointments() {
return `<tr class="termin-row" data-id="${esc(tt.id)}">
<td class="frist-col-check"><span class="termin-dot ${typeClass}" /></td>
<td>${esc(fmtDateTimeLocal(tt.start_at))}</td>
<td>${esc(tt.title)}</td>
<td>${esc(tt.title)}${attributionChip(tt.project_id, tt.project_title)}</td>
<td>${esc(tt.location ?? "")}</td>
<td><span class="termin-type-chip ${typeClass}">${esc(typeLabel)}</span></td>
</tr>`;
@@ -443,7 +583,7 @@ function renderDeadlines() {
aria-label="${esc(t("deadlines.complete.action"))}" />
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
@@ -477,6 +617,19 @@ function renderDeadlines() {
});
}
// attributionChip renders a small inline chip showing which descendant
// project a row actually anchors on, when the row is from an aggregated
// subtree result and not from the project being viewed (t-paliad-139).
// Returns "" when the row's project is the current page or attribution
// data is missing.
function attributionChip(rowProjectID?: string, rowProjectTitle?: string): string {
if (!project) return "";
if (!rowProjectID || !rowProjectTitle) return "";
if (rowProjectID === project.id) return "";
const label = t("aggregation.attribution.on") || "auf";
return ` <span class="aggregation-chip" title="${escAttr(rowProjectTitle)}">${esc(label)}: ${esc(rowProjectTitle)}</span>`;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
@@ -580,7 +733,7 @@ function renderEvents() {
return `<li class="entity-event">
<div class="entity-event-date">${fmtDateTime(e.created_at)}</div>
<div class="entity-event-body">
<div class="entity-event-title">${titleHTML}</div>
<div class="entity-event-title">${titleHTML}${attributionChip(e.project_id, e.project_title)}</div>
${description ? `<div class="entity-event-desc">${esc(description)}</div>` : ""}
</div>
</li>`;
@@ -1117,6 +1270,10 @@ async function main() {
return;
}
// Read subtree mode from URL once at startup; subsequent toggles update
// the URL via persistSubtreeMode (replaceState — back-button friendly).
subtreeMode = parseSubtreeMode();
await loadMe();
const ok = await loadProject(id);
if (!ok || !project) {
@@ -1133,6 +1290,10 @@ async function main() {
loadAncestors(id),
loadChildren(id),
loadTeam(id),
loadDescendantStaffed(id),
loadDerivedMembers(id),
loadAttachedUnits(id),
loadAllUnits(),
loadUserList(),
]);
@@ -1155,10 +1316,117 @@ async function main() {
initTeamForm(id);
initDelete();
initEventsLoadMore();
initSubtreeToggles(id);
initAttachUnitForm(id);
initNotesContainer(id);
showTab(parseTab());
}
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
// tab (project lead / global_admin only). The select is populated from
// /api/partner-units excluding units already attached.
function initAttachUnitForm(id: string) {
const wrap = document.getElementById("unit-attach-form-wrap");
const form = document.getElementById("unit-attach-form") as HTMLFormElement | null;
const showBtn = document.getElementById("unit-attach-show") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("unit-attach-cancel") as HTMLButtonElement | null;
const select = document.getElementById("unit-attach-select") as HTMLSelectElement | null;
if (!wrap || !form || !showBtn || !cancelBtn || !select) return;
if (!canManagePartnerUnits()) {
showBtn.style.display = "none";
return;
}
const refreshSelect = () => {
const attachedIDs = new Set(attachedUnits.map((u) => u.partner_unit_id));
const placeholder = `<option value="">${esc(t("projects.team.units.choose") || "Bitte Unit wählen…")}</option>`;
const opts = allUnits
.filter((u) => !attachedIDs.has(u.id))
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
.join("");
select.innerHTML = placeholder + opts;
};
refreshSelect();
showBtn.addEventListener("click", () => {
refreshSelect();
wrap.style.display = "";
showBtn.style.display = "none";
});
cancelBtn.addEventListener("click", () => {
form.reset();
wrap.style.display = "none";
showBtn.style.display = "";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const unitID = select.value;
if (!unitID) return;
const rolePA = (document.getElementById("unit-attach-role-pa") as HTMLInputElement).checked;
const roleSenior = (document.getElementById("unit-attach-role-senior_pa") as HTMLInputElement).checked;
const roleAtty = (document.getElementById("unit-attach-role-attorney") as HTMLInputElement).checked;
const grantsAuthority = (document.getElementById("unit-attach-authority") as HTMLInputElement).checked;
const roles: string[] = [];
if (rolePA) roles.push("pa");
if (roleSenior) roles.push("senior_pa");
if (roleAtty) roles.push("attorney");
if (roles.length === 0) {
// Defaults: pa + senior_pa.
roles.push("pa", "senior_pa");
}
const resp = await fetch(`/api/projects/${id}/partner-units`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
partner_unit_id: unitID,
derive_unit_roles: roles,
derive_grants_authority: grantsAuthority,
}),
});
if (resp.ok) {
form.reset();
wrap.style.display = "none";
showBtn.style.display = "";
await Promise.all([loadAttachedUnits(id), loadDerivedMembers(id)]);
renderTeam();
}
});
}
// initSubtreeToggles wires the "Inkl. Unterprojekte / Nur direkt" buttons
// in the History, Deadlines, and Appointments sections. State is shared
// across the three sections (one toggle flips all) and persisted in the
// URL via ?subtree=false. Default = subtree (true).
function initSubtreeToggles(id: string) {
const buttons = document.querySelectorAll<HTMLButtonElement>(".subtree-toggle");
if (buttons.length === 0) return;
const refreshLabels = () => {
buttons.forEach((btn) => {
btn.textContent = subtreeMode
? t("aggregation.toggle.subtree")
: t("aggregation.toggle.direct_only");
btn.setAttribute("aria-pressed", subtreeMode ? "true" : "false");
btn.classList.toggle("subtree-toggle--active", !subtreeMode);
});
};
refreshLabels();
buttons.forEach((btn) => {
btn.addEventListener("click", async () => {
subtreeMode = !subtreeMode;
persistSubtreeMode();
refreshLabels();
await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]);
renderEvents();
renderDeadlines();
renderAppointments();
});
});
}
// ----- Breadcrumb + ancestor resolution -----------------------------------
function inheritedClientNumber(): string | null {
@@ -1310,6 +1578,59 @@ async function loadTeam(id: string) {
}
}
// t-paliad-139 — Team-tab subsection loaders. All three are independent so
// main() runs them in parallel.
async function loadDescendantStaffed(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/team/from-descendants`);
if (resp.ok) {
descendantStaffed = ((await resp.json()) as ProjectTeamMember[]) ?? [];
} else {
descendantStaffed = [];
}
} catch {
descendantStaffed = [];
}
}
async function loadDerivedMembers(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/team/derived`);
if (resp.ok) {
derivedMembers = ((await resp.json()) as DerivedMember[]) ?? [];
} else {
derivedMembers = [];
}
} catch {
derivedMembers = [];
}
}
async function loadAttachedUnits(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/partner-units`);
if (resp.ok) {
attachedUnits = ((await resp.json()) as AttachedUnit[]) ?? [];
} else {
attachedUnits = [];
}
} catch {
attachedUnits = [];
}
}
async function loadAllUnits() {
try {
const resp = await fetch(`/api/partner-units`);
if (resp.ok) {
const all = (await resp.json()) as { id: string; name: string; office: string }[];
allUnits = all ?? [];
}
} catch {
allUnits = [];
}
}
async function loadUserList() {
try {
const resp = await fetch("/api/users");
@@ -1322,12 +1643,23 @@ async function loadUserList() {
function renderTeam() {
const body = document.getElementById("team-body")!;
const empty = document.getElementById("team-empty")!;
if (!teamMembers.length) {
// Existing team-body shows the direct + ancestor-inherited members
// returned by /api/projects/{id}/team. The derived + descendant
// sections render into separate tbodies (added in TSX). Empty state
// applies to the union — only show when EVERY section is empty.
const totalRows =
teamMembers.length + descendantStaffed.length + derivedMembers.length;
if (totalRows === 0) {
body.innerHTML = "";
empty.style.display = "";
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
return;
}
empty.style.display = "none";
body.innerHTML = teamMembers
.map((m) => {
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
@@ -1366,6 +1698,145 @@ function renderTeam() {
}
});
});
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
}
// t-paliad-139 — "Aus Unterprojekten" subsection.
function renderDescendantStaffed() {
const section = document.getElementById("team-section-descendants");
const body = document.getElementById("team-descendants-body");
if (!section || !body) return;
if (descendantStaffed.length === 0) {
section.style.display = "none";
body.innerHTML = "";
return;
}
section.style.display = "";
body.innerHTML = descendantStaffed
.map((m) => {
const roleLabel = tDyn(`projects.team.role.${m.role}`) || m.role;
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const sourceTitle = esc(m.inherited_from_title || "");
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td><span class="projekt-team-inherited" title="${escAttr(t("aggregation.attribution.on") || "auf")}: ${sourceTitle}">&darr; ${sourceTitle}</span></td>
</tr>`;
})
.join("");
}
// t-paliad-139 — "Abgeleitet (Partner Unit)" subsection.
function renderDerivedMembers() {
const section = document.getElementById("team-section-derived");
const body = document.getElementById("team-derived-body");
if (!section || !body) return;
if (derivedMembers.length === 0) {
section.style.display = "none";
body.innerHTML = "";
return;
}
section.style.display = "";
body.innerHTML = derivedMembers
.map((m) => {
const memberships = m.memberships || [];
// Role column shows distinct unit_role values (usually one — only
// diverges if the user has different roles in different units).
const distinctRoles = Array.from(new Set(memberships.map((x) => x.unit_role)));
const roleLabel = distinctRoles
.map((r) => tDyn(`unit_role.${r}`) || r)
.join(", ");
// Herkunft column lists every (unit, role) pair so multi-unit users
// surface all their sources, not just the closest one (t-paliad-143).
// Multi-unit: bold each unit name and append the role in parentheses.
// Single-unit: bold the one unit name (matches the legacy rendering).
const sourceLabel = memberships
.map((x) => {
const name = `<strong>${esc(x.unit_name)}</strong>`;
if (memberships.length === 1) return name;
const role = esc(tDyn(`unit_role.${x.unit_role}`) || x.unit_role);
return `${name} (${role})`;
})
.join(", ");
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const authBadge = m.derive_grants_authority
? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>`
: `<span class="derived-badge">${esc(t("projects.team.derived.visibility") || "Sicht")}</span>`;
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td>${esc(t("projects.team.derived.from") || "über")}: ${sourceLabel} ${authBadge}</td>
</tr>`;
})
.join("");
}
// t-paliad-139 — Partner Units management section. Lists attached units
// with detach buttons; admin/lead can add new attachments.
function renderAttachedUnits() {
const section = document.getElementById("team-section-units");
const body = document.getElementById("team-units-body");
if (!section || !body) return;
const canManage = canManagePartnerUnits();
// Always show the section to admins/leads (even if empty so they can attach).
if (!canManage && attachedUnits.length === 0) {
section.style.display = "none";
return;
}
section.style.display = "";
if (attachedUnits.length === 0) {
body.innerHTML = `<tr><td colspan="4" class="form-hint">${esc(t("projects.team.units.empty") || "Keine Partner Units zugeordnet.")}</td></tr>`;
return;
}
body.innerHTML = attachedUnits
.map((u) => {
const roles = (u.derive_unit_roles || []).map((r) => tDyn(`unit_role.${r}`) || r).join(", ");
const auth = u.derive_grants_authority
? esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")
: esc(t("projects.team.derived.visibility") || "Sicht");
const detachBtn = canManage
? `<button type="button" class="btn-ghost btn-small unit-detach-btn" data-unit-id="${esc(u.partner_unit_id)}">${esc(t("projects.team.units.detach") || "Entfernen")}</button>`
: "";
return `<tr>
<td><strong>${esc(u.unit_name)}</strong></td>
<td>${esc(roles)}</td>
<td>${auth}</td>
<td>${u.derived_member_count} ${esc(t("projects.team.units.members") || "Mitglieder")} ${detachBtn}</td>
</tr>`;
})
.join("");
body.querySelectorAll<HTMLButtonElement>(".unit-detach-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!project) return;
const unitID = btn.dataset.unitId!;
if (!window.confirm(t("projects.team.units.confirm_detach") || "Partner Unit entfernen?")) return;
const resp = await fetch(
`/api/projects/${project.id}/partner-units/${encodeURIComponent(unitID)}`,
{ method: "DELETE" },
);
if (resp.ok) {
await Promise.all([loadAttachedUnits(project.id), loadDerivedMembers(project.id)]);
renderTeam();
}
});
});
}
// canManagePartnerUnits returns true for global_admin or this project's
// lead. Mirrors the migration-055 RLS write policy.
function canManagePartnerUnits(): boolean {
if (!me) return false;
if (me.global_role === "global_admin") return true;
if (!project) return false;
return teamMembers.some(
(m) => m.user_id === me!.id && m.role === "lead" && m.project_id === project!.id,
);
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
@@ -1383,8 +1854,24 @@ function initTeamForm(id: string) {
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
const role = document.getElementById("team-role") as HTMLSelectElement | null;
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
const hideInviteHint = () => {
if (inviteHint) inviteHint.style.display = "none";
};
const showInviteHint = (q: string) => {
if (!inviteHint || !inviteHintText) return;
const looksLikeEmail = /@/.test(q) && /\./.test(q.split("@")[1] || "");
inviteHintText.textContent = looksLikeEmail
? t("projects.detail.team.invite.hint_email") || "Niemand mit dieser E-Mail."
: t("projects.detail.team.invite.hint") || "Benutzer nicht gefunden?";
inviteHint.dataset.email = looksLikeEmail ? q : "";
inviteHint.style.display = "";
};
addBtn.addEventListener("click", () => {
form.style.display = "";
addBtn.style.display = "none";
@@ -1396,18 +1883,21 @@ function initTeamForm(id: string) {
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
hideInviteHint();
msg.textContent = "";
});
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
const q = input.value.trim();
const lc = q.toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
hideInviteHint();
return;
}
const matches = userOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(lc))
.slice(0, 8);
sugs.innerHTML = matches
.map(
@@ -1422,8 +1912,29 @@ function initTeamForm(id: string) {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
hideInviteHint();
});
});
if (matches.length === 0) {
showInviteHint(q);
} else {
hideInviteHint();
}
});
inviteBtn?.addEventListener("click", () => {
const sidebarBtn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
if (!sidebarBtn) return;
sidebarBtn.click();
const prefill = inviteHint?.dataset.email || "";
if (prefill) {
const inviteEmail = document.getElementById("invite-email") as HTMLInputElement | null;
if (inviteEmail) {
inviteEmail.value = prefill;
inviteEmail.dispatchEvent(new Event("input", { bubbles: true }));
}
}
});
form.addEventListener("submit", async (e) => {
@@ -1446,6 +1957,7 @@ function initTeamForm(id: string) {
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
hideInviteHint();
form.style.display = "none";
addBtn.style.display = "";
await loadTeam(id);

View File

@@ -72,6 +72,7 @@ export function initSidebar() {
initChangelogBadge();
initInboxBadge();
initAdminGroup();
initUserViewsGroup();
initThemeToggle();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
@@ -400,6 +401,122 @@ function initThemeToggle(): void {
render();
}
// t-paliad-144 Phase A2 — Meine Sichten group hydration. Fetches the
// caller's saved views and renders one nav item per view between the
// group label and the "+ Neue Sicht" trailing entry. Optional count
// badge per view (when show_count=true on the row). The "+ Neue Sicht"
// entry stays in the DOM unconditionally so the group has something
// to show even for first-time users.
interface UserViewLite {
id: string;
slug: string;
name: string;
icon?: string;
show_count: boolean;
}
function initUserViewsGroup(): void {
const items = document.getElementById("sidebar-views-items");
if (!items) return;
// Skip on auth-anon pages (/login, landing) — /api/user-views would 401.
if (!document.body.classList.contains("has-sidebar")) return;
fetch("/api/user-views", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((views: UserViewLite[] | null) => {
if (!views) return;
const currentPath = window.location.pathname;
items.innerHTML = "";
for (const view of views) {
items.appendChild(renderUserViewItem(view, currentPath));
}
// After rendering, kick off count refresh for views that opted in.
for (const view of views) {
if (view.show_count) {
void refreshUserViewCount(view);
}
}
})
.catch(() => {
// Silent — sidebar already shows "+ Neue Sicht" even on failure.
});
}
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
const a = document.createElement("a");
a.href = `/views/${encodeURIComponent(view.slug)}`;
const active = currentPath === a.pathname;
a.className = `sidebar-item sidebar-user-view-item${active ? " active" : ""}`;
a.dataset.slug = view.slug;
a.dataset.viewId = view.id;
const iconWrap = document.createElement("span");
iconWrap.className = "sidebar-icon";
iconWrap.innerHTML = userViewIconSvg(view.icon);
a.appendChild(iconWrap);
const label = document.createElement("span");
label.className = "sidebar-label";
label.textContent = view.name;
a.appendChild(label);
if (view.show_count) {
const badge = document.createElement("span");
badge.className = "sidebar-badge sidebar-user-view-badge";
badge.id = `sidebar-user-view-badge-${view.id}`;
badge.style.display = "none";
badge.setAttribute("aria-hidden", "true");
a.appendChild(badge);
}
return a;
}
async function refreshUserViewCount(view: UserViewLite): Promise<void> {
try {
const r = await fetch(`/api/views/${encodeURIComponent(view.slug)}/run`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) return;
const data = (await r.json()) as { rows: unknown[] };
const badge = document.getElementById(`sidebar-user-view-badge-${view.id}`);
if (!badge) return;
if (data.rows.length > 0) {
badge.textContent = String(data.rows.length);
badge.style.display = "";
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
}
// userViewIconSvg picks an SVG from a small fixed registry. Falls back
// to the folder icon for unknown / missing keys. Inline SVGs are used
// elsewhere in the sidebar (Sidebar.tsx); we duplicate a minimal subset
// here rather than re-exporting because client TS doesn't import from
// JSX-emitting modules.
function userViewIconSvg(icon?: string): string {
switch (icon) {
case "clock":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
case "calendar":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
case "bell":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
case "users":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
case "building":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/></svg>';
case "folder":
default:
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
}
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands

View File

@@ -1,5 +1,6 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
interface User {
id: string;
@@ -10,6 +11,25 @@ interface User {
job_title?: string | null;
}
interface MembershipEntry {
user_id: string;
project_ids: string[];
lead_project_ids: string[];
roles: string[];
}
interface ProjectSummary {
id: string;
title: string;
type: string;
reference?: string | null;
}
interface MeUser {
id: string;
global_role: string;
}
interface DepartmentMember {
user_id: string;
email: string;
@@ -48,9 +68,13 @@ const ROLE_ORDER = [
let users: User[] = [];
let departments: Department[] = [];
let memberships: MembershipEntry[] = [];
let projectsList: ProjectSummary[] = [];
let me: MeUser | null = null;
let groupBy: "office" | "department" = "office";
let activeOffice = "all";
let activeRole = "all";
let activeProjectIDs: Set<string> = new Set();
let searchQuery = "";
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
@@ -87,15 +111,26 @@ function initials(name: string): string {
}
async function loadAll() {
const [usersResp, deptsResp] = await Promise.all([
const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/partner-units?include=members"),
fetch("/api/team/memberships"),
fetch("/api/projects"),
fetch("/api/me"),
]);
if (usersResp.ok) users = (await usersResp.json()) as User[];
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[];
if (projectsResp.ok) {
const raw = (await projectsResp.json()) as ProjectSummary[];
projectsList = raw;
}
if (meResp.ok) me = (await meResp.json()) as MeUser;
buildOfficeFilters();
buildRoleFilters();
buildProjectFilter();
render();
updateBroadcastButton();
}
function presentOffices(): string[] {
@@ -191,6 +226,176 @@ function userMatchesRole(u: User): boolean {
return roleKey(u.job_title) === activeRole.toLowerCase();
}
// userMatchesProject returns true when the project filter is empty or
// when the user is a direct member of at least one selected project.
// Inherited memberships intentionally don't qualify here — users want
// "people I can mail on this matter", which means direct membership.
function userMatchesProject(u: User): boolean {
if (activeProjectIDs.size === 0) return true;
const m = memberships.find((m) => m.user_id === u.id);
if (!m) return false;
for (const pid of m.project_ids) {
if (activeProjectIDs.has(pid)) return true;
}
return false;
}
// canBroadcast reports whether the current user is allowed to send a
// broadcast given the active project filter. global_admin always wins.
// Otherwise the user must be a 'lead' on every project they have
// selected (or, when no project is selected, on at least one of their
// own projects).
function canBroadcast(): boolean {
if (!me) return false;
if (me.global_role === "global_admin") return true;
const myMembership = memberships.find((m) => m.user_id === me?.id);
if (!myMembership || !myMembership.lead_project_ids.length) return false;
if (activeProjectIDs.size === 0) {
// No project filter — allow when caller leads at least one project.
// Server-side check still runs per-broadcast so a non-lead can never
// actually send.
return true;
}
for (const pid of activeProjectIDs) {
if (!myMembership.lead_project_ids.includes(pid)) return false;
}
return true;
}
function buildProjectFilter() {
const container = document.getElementById("team-project-filter");
if (!container) return;
// Show only projects the caller can see — projectsList already does
// that via the visibility-gated /api/projects endpoint.
const sortedProjects = [...projectsList].sort((a, b) =>
(a.title || "").localeCompare(b.title || ""),
);
const options = sortedProjects
.map(
(p) =>
`<label class="filter-checkbox"><input type="checkbox" data-project-id="${esc(p.id)}" ${
activeProjectIDs.has(p.id) ? "checked" : ""
} /> <span>${esc(p.title)}</span></label>`,
)
.join("");
const summary = activeProjectIDs.size === 0
? (t("team.filter.project.all") || "Alle Projekte")
: `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`;
container.innerHTML = `
<button type="button" class="filter-pill team-project-trigger" data-project-trigger>
<span class="team-project-summary">${esc(t("team.filter.project") || "Projekt")}: ${esc(summary)}</span>
</button>
<div class="team-project-panel hidden" data-project-panel>
<div class="team-project-actions">
<button type="button" class="link-button" data-project-clear>${esc(t("team.filter.project.clear") || "Alle abwählen")}</button>
</div>
<div class="team-project-options">${options}</div>
</div>
`;
const trigger = container.querySelector<HTMLButtonElement>("[data-project-trigger]");
const panel = container.querySelector<HTMLDivElement>("[data-project-panel]");
trigger?.addEventListener("click", (e) => {
e.stopPropagation();
panel?.classList.toggle("hidden");
});
document.addEventListener("click", (e) => {
if (!container.contains(e.target as Node)) panel?.classList.add("hidden");
});
container.querySelectorAll<HTMLInputElement>("input[data-project-id]").forEach((cb) => {
cb.addEventListener("change", () => {
const pid = cb.dataset.projectId!;
if (cb.checked) activeProjectIDs.add(pid);
else activeProjectIDs.delete(pid);
buildProjectFilter();
render();
updateBroadcastButton();
});
});
container.querySelector<HTMLButtonElement>("[data-project-clear]")?.addEventListener("click", () => {
activeProjectIDs.clear();
buildProjectFilter();
render();
updateBroadcastButton();
});
}
function buildBroadcastButton() {
const wrap = document.getElementById("team-broadcast-wrap");
if (!wrap) return;
if (!canBroadcast()) {
wrap.innerHTML = "";
wrap.style.display = "none";
return;
}
wrap.style.display = "";
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
}
function updateBroadcastButton() {
buildBroadcastButton();
const countEl = document.getElementById("team-broadcast-count");
if (countEl) {
const n = displayedRecipients().length;
countEl.textContent = String(n);
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = n === 0;
}
}
// displayedRecipients returns the currently visible users as broadcast
// recipients. Personal placeholder fields are sourced from each user
// (display_name / first_name) and from the membership index when a
// project filter is set (role_on_project = the role on the selected
// project; falls back to first available role).
function displayedRecipients(): BroadcastRecipient[] {
const filtered = users.filter(
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
);
return filtered.map((u) => {
const m = memberships.find((m) => m.user_id === u.id);
let role = "";
if (m) {
if (activeProjectIDs.size > 0) {
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
if (idx >= 0) role = m.roles[idx];
} else if (m.roles.length > 0) {
role = m.roles[0];
}
}
return {
user_id: u.id,
email: u.email,
display_name: u.display_name,
first_name: firstName(u.display_name),
role_on_project: role,
};
});
}
function onBroadcastClick() {
const recipients = displayedRecipients();
const selectedProjectIDs = Array.from(activeProjectIDs);
// When exactly one project is selected we pass it as project_id so
// the backend can verify lead-ship on that project. With multi-
// select we leave project_id null and rely on global_admin (the
// service rejects non-admin senders without a project_id).
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
const offices = activeOffice === "all" ? [] : [activeOffice];
const roles = activeRole === "all" ? [] : [activeRole];
openBroadcastModal({
recipients,
projectID,
projectIDs: selectedProjectIDs,
offices,
roles,
});
}
function memberAsUser(m: DepartmentMember): User | undefined {
return users.find((u) => u.id === m.user_id);
}
@@ -297,8 +502,11 @@ function render() {
const empty = document.getElementById("team-empty")!;
const count = document.getElementById("team-count")!;
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesSearch(u));
const filtered = users.filter(
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
);
count.textContent = `${filtered.length} / ${users.length}`;
updateBroadcastButton();
if (filtered.length === 0) {
list.innerHTML = "";

View File

@@ -0,0 +1,282 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import {
defaultFilterSpec,
defaultRenderSpec,
type DataSource,
type FilterSpec,
type RenderShape,
type RenderSpec,
type ScopeMode,
type TimeHorizon,
type UserView,
} from "./views/types";
// View editor — /views/new (create) and /views/{slug}/edit (modify).
// The form has a small fixed set of widgets (no full predicate JSON
// editor in v1 — that's a follow-up if power users ask). Saves via
// POST/PATCH /api/user-views.
initI18n();
initSidebar();
interface EditorState {
mode: "new" | "edit";
// Set in edit mode after the existing view is fetched.
existing?: UserView;
}
let state: EditorState = { mode: "new" };
document.addEventListener("DOMContentLoaded", () => {
state = detectMode();
bindShapeToggle();
bindForm();
bindDelete();
if (state.mode === "edit") {
void loadExisting();
const heading = document.getElementById("editor-heading");
if (heading) heading.textContent = t("views.editor.heading.edit");
const del = document.getElementById("editor-delete");
if (del) del.hidden = false;
} else {
seedDefaults();
}
});
function detectMode(): EditorState {
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
if (m) return { mode: "edit" };
return { mode: "new" };
}
function editSlugFromPath(): string | null {
const m = window.location.pathname.match(/^\/views\/([^\/]+)\/edit$/);
return m ? decodeURIComponent(m[1]) : null;
}
async function loadExisting(): Promise<void> {
const slug = editSlugFromPath();
if (!slug) return;
const r = await fetch("/api/user-views", { credentials: "include" });
if (!r.ok) {
showFeedback("error", t("views.editor.error.load_failed"));
return;
}
const list = (await r.json()) as UserView[];
const view = list.find((v) => v.slug === slug);
if (!view) {
showFeedback("error", t("views.error.not_found"));
return;
}
state.existing = view;
populateForm(view);
}
function populateForm(view: UserView): void {
setInputValue("editor-name", view.name);
setInputValue("editor-slug", view.slug);
setSelectValue("editor-icon", view.icon ?? "");
setCheckboxValue("editor-show-count", view.show_count);
for (const src of ["deadline", "appointment", "project_event", "approval_request"] as DataSource[]) {
setCheckboxValue(`source-${src}`, view.filter_spec.sources.includes(src), { name: "source", value: src });
}
setSelectValue("editor-scope-mode", view.filter_spec.scope.projects.mode);
setCheckboxValue("editor-personal-only", view.filter_spec.scope.personal_only ?? false);
setSelectValue("editor-time-horizon", view.filter_spec.time.horizon);
setSelectValue("editor-shape", view.render_spec.shape);
setSelectValue("editor-list-density", view.render_spec.list?.density ?? "comfortable");
// Hide list-density when shape isn't list.
toggleListDensityVisibility(view.render_spec.shape);
}
function seedDefaults(): void {
// Seed the form with a useful blank-slate spec.
const filter = defaultFilterSpec();
const render = defaultRenderSpec();
for (const src of filter.sources) {
setCheckboxValue(`source-${src}`, true, { name: "source", value: src });
}
setSelectValue("editor-scope-mode", filter.scope.projects.mode);
setSelectValue("editor-time-horizon", filter.time.horizon);
setSelectValue("editor-shape", render.shape);
setSelectValue("editor-list-density", render.list?.density ?? "comfortable");
toggleListDensityVisibility(render.shape);
}
function bindShapeToggle(): void {
const shapeSelect = document.getElementById("editor-shape") as HTMLSelectElement | null;
if (!shapeSelect) return;
shapeSelect.addEventListener("change", () => {
toggleListDensityVisibility(shapeSelect.value as RenderShape);
});
}
function toggleListDensityVisibility(shape: RenderShape): void {
const group = document.getElementById("editor-list-density-group");
if (!group) return;
group.style.display = shape === "list" ? "" : "none";
}
function bindForm(): void {
const form = document.getElementById("editor-form") as HTMLFormElement | null;
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const payload = collectForm();
if (!payload) return; // collectForm already shows feedback
if (state.mode === "edit" && state.existing) {
await save("PATCH", `/api/user-views/${state.existing.id}`, payload);
} else {
await save("POST", `/api/user-views`, payload);
}
});
}
function bindDelete(): void {
const btn = document.getElementById("editor-delete") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!state.existing) return;
if (!confirm(t("views.editor.confirm_delete"))) return;
const r = await fetch(`/api/user-views/${state.existing.id}`, {
method: "DELETE",
credentials: "include",
});
if (!r.ok) {
showFeedback("error", t("views.editor.error.delete_failed"));
return;
}
window.location.href = "/views";
});
}
interface CreatePayload {
slug: string;
name: string;
icon?: string;
filter_spec: FilterSpec;
render_spec: RenderSpec;
show_count: boolean;
}
function collectForm(): CreatePayload | null {
const name = getInputValue("editor-name").trim();
const slug = getInputValue("editor-slug").trim();
const iconRaw = getSelectValue("editor-icon");
const icon = iconRaw === "" ? undefined : iconRaw;
const showCount = getCheckboxValue("editor-show-count");
if (!name) {
showFeedback("error", t("views.editor.error.name_required"));
return null;
}
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(slug)) {
showFeedback("error", t("views.editor.error.slug_format"));
return null;
}
const sources: DataSource[] = (["deadline", "appointment", "project_event", "approval_request"] as DataSource[])
.filter((s) => getCheckboxValue(`source-${s}`));
if (sources.length === 0) {
showFeedback("error", t("views.editor.error.sources_required"));
return null;
}
const scopeMode = getSelectValue("editor-scope-mode") as ScopeMode;
const personalOnly = getCheckboxValue("editor-personal-only");
const horizon = getSelectValue("editor-time-horizon") as TimeHorizon;
const shape = getSelectValue("editor-shape") as RenderShape;
const listDensity = getSelectValue("editor-list-density") as "comfortable" | "compact";
const filter: FilterSpec = {
version: 1,
sources,
scope: {
projects: { mode: scopeMode },
personal_only: personalOnly,
},
time: { horizon, field: "auto" },
};
const render: RenderSpec = {
shape,
list: shape === "list" ? { density: listDensity, sort: "date_asc" } : undefined,
cards: shape === "cards" ? { group_by: "day", sort: "date_asc" } : undefined,
calendar: shape === "calendar" ? { default_view: "month" } : undefined,
};
return { slug, name, icon, filter_spec: filter, render_spec: render, show_count: showCount };
}
async function save(method: "POST" | "PATCH", url: string, payload: CreatePayload): Promise<void> {
const r = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string }));
showFeedback("error", body.error || `${r.status}: ${r.statusText}`);
return;
}
// Saved — go to the saved view.
window.location.href = `/views/${encodeURIComponent(payload.slug)}`;
}
// ----- DOM helpers -----
function getInputValue(id: string): string {
const el = document.getElementById(id) as HTMLInputElement | null;
return el?.value ?? "";
}
function setInputValue(id: string, value: string): void {
const el = document.getElementById(id) as HTMLInputElement | null;
if (el) el.value = value;
}
function getSelectValue(id: string): string {
const el = document.getElementById(id) as HTMLSelectElement | null;
return el?.value ?? "";
}
function setSelectValue(id: string, value: string): void {
const el = document.getElementById(id) as HTMLSelectElement | null;
if (el) el.value = value;
}
function getCheckboxValue(id: string): boolean {
const el = document.getElementById(id) as HTMLInputElement | null;
if (el) return el.checked;
// Fallback: lookup by name+value (for the source-* checkbox group).
const m = id.match(/^source-(.+)$/);
if (m) {
const cb = document.querySelector<HTMLInputElement>(`input[name="source"][value="${m[1]}"]`);
return !!cb?.checked;
}
return false;
}
function setCheckboxValue(id: string, value: boolean, fallback?: { name: string; value: string }): void {
let el = document.getElementById(id) as HTMLInputElement | null;
if (!el && fallback) {
el = document.querySelector<HTMLInputElement>(`input[name="${fallback.name}"][value="${fallback.value}"]`);
}
if (el) el.checked = value;
}
function showFeedback(kind: "success" | "error", text: string): void {
const el = document.getElementById("editor-feedback");
if (!el) return;
el.textContent = text;
el.classList.remove("form-msg-success", "form-msg-error");
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
el.hidden = false;
}

View File

@@ -0,0 +1,251 @@
import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
// shape component. Shape-switcher chips toggle the live render without
// re-fetching (the rows are already in memory).
initI18n();
initSidebar();
interface ViewMeta {
// For saved views: identifies the row for touch/edit/delete.
user_view_id?: string;
// Display name + slug.
name: string;
slug: string;
// Filter + render specs (may be overridden by slug detection).
filter: FilterSpec;
render: RenderSpec;
// Whether this is a code-resident SystemView.
is_system: boolean;
}
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
bindToastClose();
void hydrate();
});
async function hydrate(): Promise<void> {
const slug = pathSlug();
if (!slug) {
// /views with no slug → empty / onboarding state.
const onboarding = document.getElementById("views-onboarding");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = true;
if (onboarding) onboarding.hidden = false;
return;
}
// Resolve the view: try system first, then user.
const meta = await resolveMeta(slug);
if (!meta) {
showError(t("views.error.not_found"));
return;
}
currentMeta = meta;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
}
async function resolveMeta(slug: string): Promise<ViewMeta | null> {
// Try the system view list first — cheap, code-resident.
try {
const r = await fetch("/api/views/system", { credentials: "include" });
if (r.ok) {
const list = (await r.json()) as Array<{ Slug: string; Name: string; Filter: FilterSpec; Render: RenderSpec }>;
const sys = list.find((sv) => sv.Slug === slug);
if (sys) {
return { name: sys.Name, slug: sys.Slug, filter: sys.Filter, render: sys.Render, is_system: true };
}
}
} catch (_e) {
// fall through to user lookup
}
// Try a saved user view.
try {
const r = await fetch("/api/user-views", { credentials: "include" });
if (r.ok) {
const list = (await r.json()) as UserView[];
const v = list.find((uv) => uv.slug === slug);
if (v) {
return {
user_view_id: v.id,
name: v.name,
slug: v.slug,
filter: v.filter_spec,
render: v.render_spec,
is_system: false,
};
}
}
} catch (_e) { /* noop */ }
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
const loading = document.getElementById("views-loading");
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
const toolbar = document.getElementById("views-toolbar");
if (loading) loading.hidden = false;
if (empty) empty.hidden = true;
if (errorEl) errorEl.hidden = true;
if (toolbar) toolbar.hidden = false;
let result: ViewRunResult;
try {
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) {
showError(`${r.status}: ${r.statusText}`);
return;
}
result = (await r.json()) as ViewRunResult;
} catch (e) {
showError(t("views.error.network"));
return;
}
if (loading) loading.hidden = true;
currentRows = result;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
}
return;
}
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
});
}
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
const host = document.getElementById(`views-shape-${shape}`);
if (!host) return;
switch (shape) {
case "list":
renderListShape(host, rows, render);
break;
case "cards":
renderCardsShape(host, rows, render);
break;
case "calendar":
renderCalendarShape(host, rows, render);
break;
}
}
function bindShapeChips(): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.addEventListener("click", () => {
const shape = (btn.dataset.shape ?? "list") as RenderShape;
if (!currentMeta || !currentRows) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});
});
}
function updateHeader(meta: ViewMeta): void {
const heading = document.getElementById("views-heading");
if (heading) heading.textContent = meta.name;
const subtitle = document.getElementById("views-subtitle");
if (subtitle) subtitle.textContent = filterSummary(meta.filter);
const actions = document.getElementById("views-header-actions");
if (actions) {
actions.innerHTML = "";
if (!meta.is_system && meta.user_view_id) {
const editLink = document.createElement("a");
editLink.href = `/views/${encodeURIComponent(meta.slug)}/edit`;
editLink.className = "btn-secondary btn-small";
editLink.textContent = t("views.action.edit");
actions.appendChild(editLink);
}
}
}
function filterSummary(filter: FilterSpec): string {
const parts: string[] = [];
// Sources
parts.push(filter.sources.map((s) => t(("views.source." + s) as I18nKey)).join(" + "));
// Time
parts.push(t(("views.horizon." + filter.time.horizon) as I18nKey));
// Scope
if (filter.scope.personal_only) {
parts.push(t("views.scope.personal_only"));
} else if (filter.scope.projects.mode !== "all_visible") {
parts.push(t(("views.scope." + filter.scope.projects.mode) as I18nKey));
}
return parts.join(" · ");
}
function showError(message: string): void {
const loading = document.getElementById("views-loading");
const errorEl = document.getElementById("views-error");
const msg = document.getElementById("views-error-message");
if (loading) loading.hidden = true;
if (errorEl) errorEl.hidden = false;
if (msg) msg.textContent = message;
}
function showInaccessibleToast(count: number): void {
const toast = document.getElementById("views-toast");
const text = document.getElementById("views-toast-text");
if (!toast || !text) return;
text.textContent = count === 1
? t("views.toast.inaccessible_one")
: t("views.toast.inaccessible_n").replace("{n}", String(count));
toast.hidden = false;
}
function bindToastClose(): void {
const close = document.getElementById("views-toast-close");
const toast = document.getElementById("views-toast");
if (!close || !toast) return;
close.addEventListener("click", () => { toast.hidden = true; });
}
function pathSlug(): string | null {
const m = window.location.pathname.match(/^\/views\/([^\/]+)$/);
if (!m) return null;
return decodeURIComponent(m[1]);
}
function fireAndForget(url: string, method: string): void {
fetch(url, { method, credentials: "include" }).catch(() => { /* noop */ });
}

View File

@@ -0,0 +1,129 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month grid. Toggleable to week-view via per-shape
// config. Mirrors the look of /events?view=calendar but generic across
// sources.
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
const view = cfg.default_view ?? "month";
// 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 wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
const monthRef = pickMonthAnchor(rows);
wrap.appendChild(renderMonth(monthRef, rows));
host.appendChild(wrap);
}
function renderMonth(anchor: Date, rows: ViewRow[]): 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);
// Weekday headers (Mon-Sun, ISO week).
const weekdayBar = document.createElement("div");
weekdayBar.className = "views-calendar-weekdays";
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);
weekdayBar.appendChild(cell);
}
wrap.appendChild(weekdayBar);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
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).
const byDate = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
const key = isoDate(d);
const arr = byDate.get(key);
if (arr) arr.push(row);
else byDate.set(key, [row]);
}
for (let day = 1; day <= daysInMonth; day++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
const dayLabel = document.createElement("div");
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(day);
cell.appendChild(dayLabel);
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
const dayRows = byDate.get(dateKey) ?? [];
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, 3);
for (const row of visible) {
const li = document.createElement("li");
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
li.textContent = row.title;
li.title = row.title + (row.project_title ? `${row.project_title}` : "");
ul.appendChild(li);
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
more.className = "views-calendar-pill views-calendar-pill--more";
more.textContent = `+${dayRows.length - visible.length}`;
ul.appendChild(more);
}
cell.appendChild(ul);
}
grid.appendChild(cell);
}
wrap.appendChild(grid);
return wrap;
}
function pickMonthAnchor(rows: ViewRow[]): Date {
// Anchor on the first row's month, or "this month" if empty.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return d;
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
}
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}`;
}

View File

@@ -0,0 +1,118 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-cards: day-grouped chronological cards. Same layout style as the
// existing /agenda timeline; works for any source mix.
export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.cards ?? {};
const groupBy = cfg.group_by ?? "day";
const sort = cfg.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (groupBy === "none") {
host.appendChild(renderCardList(sorted));
return;
}
const groups = groupRows(sorted, groupBy);
for (const [key, items] of groups) {
const section = document.createElement("section");
section.className = "views-cards-day";
const heading = document.createElement("h2");
heading.className = "views-cards-day-heading";
heading.textContent = key;
section.appendChild(heading);
section.appendChild(renderCardList(items));
host.appendChild(section);
}
}
function renderCardList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-cards-list";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-card views-card--${row.kind}`;
const head = document.createElement("div");
head.className = "views-card-head";
const kind = document.createElement("span");
kind.className = "views-card-kind";
kind.textContent = t(("views.kind." + row.kind) as I18nKey);
head.appendChild(kind);
const title = document.createElement("h3");
title.className = "views-card-title";
title.textContent = row.title;
head.appendChild(title);
li.appendChild(head);
const meta = document.createElement("div");
meta.className = "views-card-meta";
const time = document.createElement("span");
time.textContent = formatTime(row.event_date);
meta.appendChild(time);
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-card-project";
proj.textContent = row.project_title;
meta.appendChild(proj);
}
if (row.actor_name) {
const actor = document.createElement("span");
actor.className = "views-card-actor";
actor.textContent = row.actor_name;
meta.appendChild(actor);
}
li.appendChild(meta);
if (row.subtitle) {
const sub = document.createElement("p");
sub.className = "views-card-subtitle";
sub.textContent = row.subtitle;
li.appendChild(sub);
}
ul.appendChild(li);
}
return ul;
}
function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, ViewRow[]]> {
const map = new Map<string, ViewRow[]>();
for (const row of rows) {
const key = bucketKey(row.event_date, groupBy);
const arr = map.get(key);
if (arr) arr.push(row);
else map.set(key, [row]);
}
return Array.from(map.entries());
}
function bucketKey(iso: string, groupBy: "day" | "week"): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (groupBy === "week") {
// Round down to Monday, format as "KW NN, YYYY".
const monday = new Date(d);
const day = monday.getDay() || 7; // Sunday=0 → 7
monday.setDate(monday.getDate() - day + 1);
const yearStart = new Date(Date.UTC(monday.getFullYear(), 0, 1));
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
return `KW ${weekNo}, ${monday.getFullYear()}`;
}
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" });
}

View File

@@ -0,0 +1,181 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-list: renders ViewRows as a table (density=comfortable) or a
// compact one-line stream (density=compact). The "activity feed" look
// is just density=compact + actor/time columns — see Q4 lock-in
// 2026-05-07 (3 shapes; no separate "activity").
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const list = render.list ?? {};
const density = list.density ?? "comfortable";
const sort = list.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (density === "compact") {
host.appendChild(renderCompact(sorted));
} else {
host.appendChild(renderTable(sorted, list.columns ?? defaultColumns(rows)));
}
}
function renderCompact(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-list views-list--compact";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-list-row views-list-row--${row.kind}`;
const time = document.createElement("span");
time.className = "views-list-time";
time.textContent = formatRelative(row.event_date);
li.appendChild(time);
const kindIcon = document.createElement("span");
kindIcon.className = "views-list-kind";
kindIcon.textContent = kindLabel(row.kind);
li.appendChild(kindIcon);
const title = document.createElement("span");
title.className = "views-list-title";
title.textContent = row.title;
li.appendChild(title);
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-list-project";
proj.textContent = row.project_title;
li.appendChild(proj);
}
if (row.actor_name) {
const actor = document.createElement("span");
actor.className = "views-list-actor";
actor.textContent = row.actor_name;
li.appendChild(actor);
}
if (row.subtitle) {
const sub = document.createElement("span");
sub.className = "views-list-subtitle";
sub.textContent = row.subtitle;
li.appendChild(sub);
}
ul.appendChild(li);
}
return ul;
}
function renderTable(rows: ViewRow[], columns: string[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "entity-table-wrap";
const table = document.createElement("table");
table.className = "entity-table views-list views-list--table entity-table--readonly";
const thead = document.createElement("thead");
const trHead = document.createElement("tr");
for (const col of columns) {
const th = document.createElement("th");
th.textContent = t(("views.col." + col) as I18nKey);
trHead.appendChild(th);
}
thead.appendChild(trHead);
table.appendChild(thead);
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
tr.className = `views-table-row views-table-row--${row.kind}`;
for (const col of columns) {
const td = document.createElement("td");
td.textContent = formatColumn(row, col);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
wrap.appendChild(table);
return wrap;
}
function defaultColumns(rows: ViewRow[]): string[] {
// Pick a sensible default column set from the kinds present in the
// result. Keeps the UI honest when a user lands on a saved view that
// has no explicit list.columns.
const kinds = new Set(rows.map((r) => r.kind));
if (kinds.has("project_event") || kinds.has("approval_request")) {
return ["time", "actor", "title", "project"];
}
if (kinds.has("appointment")) {
return ["date", "title", "project", "location"];
}
return ["date", "title", "project", "status"];
}
function formatColumn(row: ViewRow, col: string): string {
switch (col) {
case "date":
return formatDate(row.event_date);
case "time":
return formatRelative(row.event_date);
case "title":
return row.title;
case "project":
return row.project_title ?? "—";
case "actor":
return row.actor_name ?? "—";
case "status": {
const s = (row.detail.status as string | undefined) ?? "";
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
}
case "rule":
return (row.detail.rule_code as string | undefined) ?? "—";
case "event_type":
return (row.detail.event_type as string | undefined) ?? "—";
case "location":
return (row.detail.location as string | undefined) ?? "—";
case "appointment_type":
return (row.detail.appointment_type as string | undefined) ?? "—";
case "approval_status":
return (row.detail.approval_status as string | undefined) ?? "—";
case "decided_by":
return (row.detail.decider_name as string | undefined) ?? "—";
case "kind":
return kindLabel(row.kind);
default:
return "";
}
}
function kindLabel(kind: string): string {
return t(("views.kind." + kind) as I18nKey);
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric", month: "2-digit", day: "2-digit",
});
}
function formatRelative(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
const min = Math.floor(sec / 60);
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
}

View File

@@ -0,0 +1,159 @@
// Shared TypeScript types for the Custom Views frontend.
//
// These mirror the Go shapes in internal/services/{filter_spec,
// render_spec,view_service,user_view_service}.go. Keep field names + enum
// values in sync — the substrate's validator will reject anything else.
export type DataSource = "deadline" | "appointment" | "project_event" | "approval_request";
export type ScopeMode = "all_visible" | "my_subtree" | "explicit";
export interface ScopeProjects {
mode: ScopeMode;
ids?: string[];
}
export interface ScopeSpec {
projects: ScopeProjects;
personal_only?: boolean;
}
export type TimeHorizon =
| "next_7d" | "next_30d" | "next_90d"
| "past_30d" | "past_90d"
| "any" | "all" | "custom";
export type TimeField = "auto" | "created_at";
export interface TimeSpec {
horizon: TimeHorizon;
field?: TimeField;
from?: string; // ISO 8601
to?: string;
}
export interface DeadlinePredicates {
status?: string[];
approval_status?: string[];
event_types?: string[];
include_untyped?: boolean;
}
export interface AppointmentPredicates {
approval_status?: string[];
appointment_types?: string[];
}
export interface ProjectEventPredicates {
event_types?: string[];
}
export interface ApprovalRequestPredicates {
viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
status?: string[];
entity_types?: string[];
}
export interface Predicates {
deadline?: DeadlinePredicates;
appointment?: AppointmentPredicates;
project_event?: ProjectEventPredicates;
approval_request?: ApprovalRequestPredicates;
}
export interface FilterSpec {
version: number;
sources: DataSource[];
scope: ScopeSpec;
time: TimeSpec;
predicates?: Partial<Record<DataSource, Predicates>>;
}
export type RenderShape = "list" | "cards" | "calendar";
export interface ListConfig {
columns?: string[];
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
}
export interface CardsConfig {
group_by?: "day" | "week" | "none";
sort?: "date_asc" | "date_desc";
show_empty_days?: boolean;
}
export interface CalendarConfig {
default_view?: "month" | "week";
show_weekends?: boolean;
}
export interface RenderSpec {
shape: RenderShape;
list?: ListConfig;
cards?: CardsConfig;
calendar?: CalendarConfig;
}
// ViewRow — the discriminated row shape from ViewService.RunSpec.
export interface ViewRow {
kind: DataSource;
id: string;
title: string;
subtitle?: string;
event_date: string;
project_id?: string;
project_title?: string;
project_reference?: string;
project_type?: string;
actor_id?: string;
actor_name?: string;
detail: Record<string, unknown>;
}
export interface ViewRunResult {
rows: ViewRow[];
inaccessible_project_ids?: string[];
}
// UserView — the persisted shape from /api/user-views.
export interface UserView {
id: string;
user_id: string;
slug: string;
name: string;
icon?: string;
filter_spec: FilterSpec;
render_spec: RenderSpec;
sort_order: number;
show_count: boolean;
last_used_at?: string;
created_at: string;
updated_at: string;
}
// SystemView — code-resident definition from /api/views/system.
export interface SystemView {
Slug: string;
Name: string;
Filter: FilterSpec;
Render: RenderSpec;
}
export const SPEC_VERSION = 1;
export function defaultFilterSpec(): FilterSpec {
return {
version: SPEC_VERSION,
sources: ["deadline", "appointment"],
scope: { projects: { mode: "all_visible" } },
time: { horizon: "next_30d", field: "auto" },
};
}
export function defaultRenderSpec(): RenderSpec {
return {
shape: "list",
list: { sort: "date_asc", density: "comfortable" },
};
}

View File

@@ -125,6 +125,19 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
)}
{/* t-paliad-144 Phase A2 — Meine Sichten group. Hydrated by
client/sidebar.ts from /api/user-views on mount. The
"+ Neue Sicht" entry is always present so first-time
users have an obvious way in. */}
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
<div className="sidebar-group-label" data-i18n="nav.group.user_views">Meine Sichten</div>
<div className="sidebar-views-items" id="sidebar-views-items" />
<a href="/views/new" className="sidebar-item sidebar-views-new">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
<span className="sidebar-label" data-i18n="nav.user_views.new">Neue Sicht</span>
</a>
</div>
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +

View File

@@ -46,8 +46,23 @@ export type I18nKey =
| "admin.audit.source.reminder_log"
| "admin.audit.subtitle"
| "admin.audit.title"
| "admin.broadcasts.col.count"
| "admin.broadcasts.col.sender"
| "admin.broadcasts.col.sent_at"
| "admin.broadcasts.col.subject"
| "admin.broadcasts.detail.delivered"
| "admin.broadcasts.detail.failed"
| "admin.broadcasts.detail.recipients"
| "admin.broadcasts.detail.sent_by"
| "admin.broadcasts.empty"
| "admin.broadcasts.heading"
| "admin.broadcasts.loading"
| "admin.broadcasts.subtitle"
| "admin.broadcasts.title"
| "admin.card.audit.desc"
| "admin.card.audit.title"
| "admin.card.broadcasts.desc"
| "admin.card.broadcasts.title"
| "admin.card.email_templates.desc"
| "admin.card.email_templates.title"
| "admin.card.event_types.desc"
@@ -167,6 +182,7 @@ export type I18nKey =
| "admin.partner_units.error.user_required"
| "admin.partner_units.feedback.created"
| "admin.partner_units.feedback.deleted"
| "admin.partner_units.feedback.role_updated"
| "admin.partner_units.feedback.updated"
| "admin.partner_units.heading"
| "admin.partner_units.loading"
@@ -177,6 +193,7 @@ export type I18nKey =
| "admin.partner_units.member.heading"
| "admin.partner_units.member.placeholder"
| "admin.partner_units.member.remove"
| "admin.partner_units.member.role"
| "admin.partner_units.new"
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
@@ -254,6 +271,9 @@ export type I18nKey =
| "agenda.urgency.this_week"
| "agenda.urgency.today"
| "agenda.urgency.tomorrow"
| "aggregation.attribution.on"
| "aggregation.toggle.direct_only"
| "aggregation.toggle.subtree"
| "appointments.col.akte"
| "appointments.col.location"
| "appointments.col.start"
@@ -323,6 +343,7 @@ export type I18nKey =
| "approvals.action.revoke"
| "approvals.decided_by"
| "approvals.decision_kind.admin_override"
| "approvals.decision_kind.derived_peer"
| "approvals.decision_kind.peer"
| "approvals.diff.after"
| "approvals.diff.before"
@@ -506,6 +527,10 @@ export type I18nKey =
| "checklisten.tab.templates"
| "checklisten.title"
| "common.cancel"
| "common.close"
| "common.forbidden"
| "common.load_error"
| "common.loading"
| "dashboard.action.short.akte_archived"
| "dashboard.action.short.akte_created"
| "dashboard.action.short.appointment_approval_approved"
@@ -1288,6 +1313,7 @@ export type I18nKey =
| "nav.group.einstellungen"
| "nav.group.ressourcen"
| "nav.group.uebersicht"
| "nav.group.user_views"
| "nav.group.werkzeuge"
| "nav.group.wissen"
| "nav.home"
@@ -1300,6 +1326,7 @@ export type I18nKey =
| "nav.soon.tooltip"
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
| "notes.cancel"
| "notes.delete"
| "notes.delete.confirm"
@@ -1447,6 +1474,9 @@ export type I18nKey =
| "projects.detail.team.form.role"
| "projects.detail.team.form.submit"
| "projects.detail.team.form.user"
| "projects.detail.team.invite.cta"
| "projects.detail.team.invite.hint"
| "projects.detail.team.invite.hint_email"
| "projects.detail.team.remove"
| "projects.detail.title"
| "projects.detail.verlauf.empty"
@@ -1517,6 +1547,10 @@ export type I18nKey =
| "projects.status.completed"
| "projects.submit"
| "projects.subtitle"
| "projects.team.derived.authority"
| "projects.team.derived.authority.hint"
| "projects.team.derived.from"
| "projects.team.derived.visibility"
| "projects.team.direct"
| "projects.team.inherited.hint"
| "projects.team.role.associate"
@@ -1526,6 +1560,24 @@ export type I18nKey =
| "projects.team.role.observer"
| "projects.team.role.of_counsel"
| "projects.team.role.pa"
| "projects.team.section.derived"
| "projects.team.section.derived.hint"
| "projects.team.section.from_descendants"
| "projects.team.section.from_descendants.hint"
| "projects.team.section.units"
| "projects.team.section.units.hint"
| "projects.team.units.attach"
| "projects.team.units.choose"
| "projects.team.units.col.authority"
| "projects.team.units.col.derive_roles"
| "projects.team.units.col.name"
| "projects.team.units.confirm_detach"
| "projects.team.units.derive_roles"
| "projects.team.units.detach"
| "projects.team.units.empty"
| "projects.team.units.grants_authority"
| "projects.team.units.members"
| "projects.team.units.select"
| "projects.title"
| "projects.tree.deadlines.open"
| "projects.tree.deadlines.overdue"
@@ -1552,10 +1604,36 @@ export type I18nKey =
| "search.no_results"
| "search.placeholder"
| "sidebar.resize.title"
| "team.broadcast.body"
| "team.broadcast.body_placeholder"
| "team.broadcast.button"
| "team.broadcast.error.body_required"
| "team.broadcast.error.no_recipients"
| "team.broadcast.error.subject_required"
| "team.broadcast.error.too_many"
| "team.broadcast.markdown_hint"
| "team.broadcast.placeholders_hint"
| "team.broadcast.recipients"
| "team.broadcast.send"
| "team.broadcast.sending"
| "team.broadcast.sent"
| "team.broadcast.show_all"
| "team.broadcast.subject"
| "team.broadcast.success"
| "team.broadcast.template"
| "team.broadcast.template.deadline_digest"
| "team.broadcast.template.invitation"
| "team.broadcast.template_freeform"
| "team.broadcast.template_optional"
| "team.broadcast.title"
| "team.dept.lead"
| "team.dept.unassigned"
| "team.empty"
| "team.filter.all"
| "team.filter.project"
| "team.filter.project.all"
| "team.filter.project.clear"
| "team.filter.project.selected"
| "team.filter.role"
| "team.group.department"
| "team.group.office"
@@ -1580,4 +1658,99 @@ export type I18nKey =
| "theme.toggle.cycle.dark"
| "theme.toggle.cycle.light"
| "theme.toggle.dark"
| "theme.toggle.light";
| "theme.toggle.light"
| "unit_role.attorney"
| "unit_role.lead"
| "unit_role.pa"
| "unit_role.paralegal"
| "unit_role.senior_pa"
| "views.action.edit"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"
| "views.col.approval_status"
| "views.col.date"
| "views.col.decided_by"
| "views.col.event_type"
| "views.col.kind"
| "views.col.location"
| "views.col.project"
| "views.col.rule"
| "views.col.status"
| "views.col.time"
| "views.col.title"
| "views.density.comfortable"
| "views.density.compact"
| "views.editor.cancel"
| "views.editor.confirm_delete"
| "views.editor.delete"
| "views.editor.error.delete_failed"
| "views.editor.error.load_failed"
| "views.editor.error.name_required"
| "views.editor.error.slug_format"
| "views.editor.error.sources_required"
| "views.editor.field.density"
| "views.editor.field.horizon"
| "views.editor.field.icon"
| "views.editor.field.name"
| "views.editor.field.personal_only"
| "views.editor.field.scope_mode"
| "views.editor.field.shape"
| "views.editor.field.show_count"
| "views.editor.field.slug"
| "views.editor.heading.edit"
| "views.editor.heading.new"
| "views.editor.hint.slug"
| "views.editor.hint.sources"
| "views.editor.icon.bell"
| "views.editor.icon.building"
| "views.editor.icon.calendar"
| "views.editor.icon.clock"
| "views.editor.icon.default"
| "views.editor.icon.folder"
| "views.editor.icon.users"
| "views.editor.save"
| "views.editor.section.identity"
| "views.editor.section.render"
| "views.editor.section.scope"
| "views.editor.section.sources"
| "views.editor.section.time"
| "views.editor.subtitle"
| "views.editor.title"
| "views.empty.title"
| "views.error.back"
| "views.error.network"
| "views.error.not_found"
| "views.heading"
| "views.horizon.all"
| "views.horizon.any"
| "views.horizon.custom"
| "views.horizon.next_30d"
| "views.horizon.next_7d"
| "views.horizon.next_90d"
| "views.horizon.past_30d"
| "views.horizon.past_90d"
| "views.kind.appointment"
| "views.kind.approval_request"
| "views.kind.deadline"
| "views.kind.project_event"
| "views.loading"
| "views.onboarding.body"
| "views.onboarding.create"
| "views.onboarding.title"
| "views.save_as"
| "views.scope.all_visible"
| "views.scope.explicit"
| "views.scope.my_subtree"
| "views.scope.personal_only"
| "views.shape.calendar"
| "views.shape.cards"
| "views.shape.list"
| "views.source.appointment"
| "views.source.approval_request"
| "views.source.deadline"
| "views.source.project_event"
| "views.subtitle"
| "views.title"
| "views.toast.inaccessible_n"
| "views.toast.inaccessible_one";

View File

@@ -54,7 +54,6 @@ export function renderInbox(): string {
<Footer />
</main>
<script src="/assets/app.js" defer />
<script src="/assets/inbox.js" defer />
</body>
</html>

View File

@@ -83,6 +83,11 @@ export function renderProjectsDetail(): string {
{/* History (Verlauf) */}
<section className="entity-tab-panel" id="tab-history">
<div className="party-controls">
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
<ul className="entity-events" id="project-events-list" />
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
Noch keine Ereignisse aufgezeichnet.
@@ -109,6 +114,10 @@ export function renderProjectsDetail(): string {
<input type="text" id="team-user-input" placeholder="Name oder E-Mail..." autocomplete="off" />
<input type="hidden" id="team-user-id" />
<div id="team-user-suggestions" className="collab-suggestions" />
<div id="team-user-invite-hint" className="collab-invite-hint" style="display:none">
<span id="team-user-invite-hint-text" data-i18n="projects.detail.team.invite.hint">Benutzer nicht gefunden?</span>
<button type="button" className="btn-secondary btn-small" id="team-user-invite-btn" data-i18n="projects.detail.team.invite.cta">Einladen</button>
</div>
</div>
<div className="form-field">
<label htmlFor="team-role" data-i18n="projects.detail.team.form.role">Rolle</label>
@@ -145,6 +154,101 @@ export function renderProjectsDetail(): string {
<p className="entity-events-empty" id="team-empty" style="display:none" data-i18n="projects.detail.team.empty">
Noch keine Teammitglieder.
</p>
{/* t-paliad-139 — Aus Unterprojekten subsection. */}
<div id="team-section-descendants" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.team.section.from_descendants">
Aus Unterprojekten
</h3>
<p className="form-hint" data-i18n="projects.team.section.from_descendants.hint">
Personen, die direkt auf einem Unterprojekt eingetragen sind und nicht auf diesem oder einem &Uuml;bergeordneten.
</p>
<table className="party-table">
<thead>
<tr>
<th data-i18n="projects.detail.team.col.name">Name</th>
<th data-i18n="projects.detail.team.col.role">Rolle</th>
<th data-i18n="projects.detail.team.col.source">Herkunft</th>
</tr>
</thead>
<tbody id="team-descendants-body" />
</table>
</div>
{/* t-paliad-139 — Abgeleitet (Partner Unit) subsection. */}
<div id="team-section-derived" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.team.section.derived">
Abgeleitet (Partner Unit)
</h3>
<p className="form-hint" data-i18n="projects.team.section.derived.hint">
Mitglieder, die &uuml;ber eine zugeordnete Partner Unit auf diesem Projekt aktiv sind.
</p>
<table className="party-table">
<thead>
<tr>
<th data-i18n="projects.detail.team.col.name">Name</th>
<th data-i18n="projects.detail.team.col.role">Rolle</th>
<th data-i18n="projects.detail.team.col.source">Herkunft</th>
</tr>
</thead>
<tbody id="team-derived-body" />
</table>
</div>
{/* t-paliad-139 — Partner Units management. */}
<div id="team-section-units" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.team.section.units">
Partner Units
</h3>
<p className="form-hint" data-i18n="projects.team.section.units.hint">
Partner Units, die auf diesem Projekt eingebunden sind. Mitglieder mit passenden Unit-Rollen werden automatisch abgeleitet.
</p>
<div className="party-controls">
<button type="button" id="unit-attach-show" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.team.units.attach">
Partner Unit zuordnen
</button>
</div>
<div id="unit-attach-form-wrap" style="display:none">
<form id="unit-attach-form" className="entity-form party-form" autocomplete="off">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="unit-attach-select" data-i18n="projects.team.units.select">Unit</label>
<select id="unit-attach-select" required />
</div>
</div>
<fieldset className="form-field">
<legend data-i18n="projects.team.units.derive_roles">Welche Unit-Rollen ableiten?</legend>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-role-pa" checked /> <span data-i18n="unit_role.pa">PA</span>
</label>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-role-senior_pa" checked /> <span data-i18n="unit_role.senior_pa">Senior PA</span>
</label>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-role-attorney" /> <span data-i18n="unit_role.attorney">Attorney</span>
</label>
</fieldset>
<label className="form-checkbox">
<input type="checkbox" id="unit-attach-authority" /> <span data-i18n="projects.team.units.grants_authority">Stimmrecht abgeben (4-Augen)</span>
</label>
<div className="form-actions">
<button type="button" className="btn-cancel" id="unit-attach-cancel" data-i18n="projects.detail.team.form.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.team.units.attach">Zuordnen</button>
</div>
</form>
</div>
<table className="party-table">
<thead>
<tr>
<th data-i18n="projects.team.units.col.name">Unit</th>
<th data-i18n="projects.team.units.col.derive_roles">Abgeleitete Rollen</th>
<th data-i18n="projects.team.units.col.authority">Authority</th>
<th />
</tr>
</thead>
<tbody id="team-units-body" />
</table>
</div>
</section>
{/* Children (Untergeordnet) */}
@@ -222,6 +326,9 @@ export function renderProjectsDetail(): string {
<a id="deadline-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.detail.deadlines.add" href="#">
Frist hinzuf&uuml;gen
</a>
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
<div className="entity-table-wrap" id="project-deadlines-tablewrap">
<table className="entity-table fristen-table">
@@ -248,6 +355,9 @@ export function renderProjectsDetail(): string {
<button type="button" id="appointment-add-btn" className="btn-primary btn-cta-lime btn-small" data-i18n="projects.detail.appointments.add">
Termin hinzuf&uuml;gen
</button>
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
<form id="project-appointment-form" className="party-form" style="display:none">

View File

@@ -714,8 +714,16 @@ main {
--color-accent-fg: var(--sidebar-text-active);
}
/* `:root.sidebar-pinned .sidebar` is the pre-paint companion to
`.sidebar.pinned` (t-paliad-142). The FOUC script in PWAHead.tsx sets
the html class before paint, so the sidebar element renders at pinned
width from frame 1; runtime initSidebar later mirrors `.pinned` onto
the element itself for the explicit pin/unpin click animation. Same
pattern as the `.has-sidebar.sidebar-pinned` / `:root.sidebar-pinned
.has-sidebar` pair below. */
.sidebar.expanded,
.sidebar.pinned {
.sidebar.pinned,
:root.sidebar-pinned .sidebar {
width: var(--sidebar-width);
}
@@ -765,7 +773,8 @@ main {
}
.sidebar.expanded .sidebar-pin,
.sidebar.pinned .sidebar-pin {
.sidebar.pinned .sidebar-pin,
:root.sidebar-pinned .sidebar .sidebar-pin {
opacity: 1;
pointer-events: auto;
}
@@ -781,7 +790,8 @@ main {
display: block;
}
.sidebar.pinned .sidebar-pin {
.sidebar.pinned .sidebar-pin,
:root.sidebar-pinned .sidebar .sidebar-pin {
color: var(--sidebar-text-active);
}
@@ -806,7 +816,8 @@ main {
}
.sidebar.expanded .sidebar-resize-handle,
.sidebar.pinned .sidebar-resize-handle {
.sidebar.pinned .sidebar-resize-handle,
:root.sidebar-pinned .sidebar .sidebar-resize-handle {
opacity: 1;
pointer-events: auto;
}
@@ -922,7 +933,8 @@ main {
}
.sidebar.expanded .sidebar-label,
.sidebar.pinned .sidebar-label {
.sidebar.pinned .sidebar-label,
:root.sidebar-pinned .sidebar .sidebar-label {
opacity: 1;
}
@@ -5250,7 +5262,8 @@ input[type="range"]::-moz-range-thumb {
.sidebar.expanded .sidebar-group-label,
.sidebar.pinned .sidebar-group-label,
.sidebar.mobile-open .sidebar-group-label {
.sidebar.mobile-open .sidebar-group-label,
:root.sidebar-pinned .sidebar .sidebar-group-label {
opacity: 0.75;
}
@@ -5856,6 +5869,17 @@ input[type="range"]::-moz-range-thumb {
padding: 0;
}
/* `.collab-suggestions` is absolute-positioned with top: 100% — needs a
positioned ancestor or it falls back to the viewport's initial
containing block and renders off-screen (text is in the DOM, but
visually invisible — m hit this on the project team-add picker after
t-141 made the dropdown content-visible but still let it float to
nowhere). Scoped via :has() so we only flip the parent's position
where it actually contains a suggestions dropdown. */
.form-field:has(> .collab-suggestions) {
position: relative;
}
.collab-suggestions {
position: absolute;
top: 100%;
@@ -5872,6 +5896,61 @@ input[type="range"]::-moz-range-thumb {
z-index: 10;
}
/* Visibility is content-driven: when innerHTML is "" the div has no children
and stays hidden; the moment a consumer renders <.collab-suggestion> rows
the dropdown shows. Keeps the JS sites (project team-add, project parent
picker, partner-units member-add) from each having to toggle display. */
.collab-suggestions:not(:empty) {
display: block;
}
.collab-suggestion {
display: block;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--color-border);
}
.collab-suggestion:last-child {
border-bottom: none;
}
.collab-suggestion:hover,
.collab-suggestion.is-active {
background: var(--color-bg-lime-tint);
}
.collab-suggestion strong {
display: block;
font-weight: 600;
font-size: 0.9rem;
}
.collab-suggestion .form-hint {
display: block;
font-size: 0.8rem;
margin-top: 0.1rem;
}
/* Inline "invite this user instead" affordance shown beneath an empty
.collab-suggestions when the typed query has no matches. */
.collab-invite-hint {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-alt, var(--color-bg-lime-tint));
border: 1px dashed var(--color-border);
border-radius: var(--radius);
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.collab-invite-hint button {
flex-shrink: 0;
}
.entity-suggestion {
width: 100%;
display: flex;
@@ -6128,6 +6207,71 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.82rem;
}
/* t-paliad-139 — subtree aggregation toggle (Inkl. Unterprojekte / Nur direkt).
Lives in .party-controls beside the section CTA. The active state shows
"Nur direkt" with a darker chrome so the user can see at a glance that
they've narrowed the view. Default (subtree) keeps standard secondary
styling. */
.subtree-toggle {
margin-left: 0.5rem;
}
.subtree-toggle--active {
background: var(--color-accent-soft, var(--color-surface));
border-color: var(--color-accent, var(--color-border));
color: var(--color-text);
}
/* Attribution chip — shows the descendant project a row anchors on when
the project detail page is aggregating. Inline-flush with the title cell;
readable on both light and dark themes. */
.aggregation-chip {
display: inline-block;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
border-radius: 9999px;
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
color: var(--color-text-muted, #555);
font-size: 0.75rem;
font-weight: normal;
vertical-align: baseline;
white-space: nowrap;
}
/* t-paliad-139 Phase 2 — Team-tab subsection headings. */
.entity-section-heading {
margin-top: 1.5rem;
margin-bottom: 0.25rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
/* Derived-membership badges. */
.derived-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.05rem 0.45rem;
border-radius: 9999px;
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
color: var(--color-text-muted, #555);
font-size: 0.72rem;
font-weight: 500;
vertical-align: baseline;
}
.derived-badge--authority {
background: var(--color-accent-soft, rgba(198, 244, 28, 0.18));
color: var(--color-text);
}
/* Inline checkbox label inside the attach-unit form. */
.form-checkbox {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-right: 1rem;
font-size: 0.9rem;
}
.party-form {
margin-bottom: 1.25rem;
padding: 1rem;
@@ -7541,7 +7685,8 @@ label.caldav-toggle-label {
.sidebar.expanded .sidebar-search-input,
.sidebar.pinned .sidebar-search-input,
.sidebar.mobile-open .sidebar-search-input {
.sidebar.mobile-open .sidebar-search-input,
:root.sidebar-pinned .sidebar .sidebar-search-input {
opacity: 1;
pointer-events: auto;
}
@@ -7571,7 +7716,8 @@ label.caldav-toggle-label {
.sidebar.expanded .sidebar-search-kbd,
.sidebar.pinned .sidebar-search-kbd,
.sidebar.mobile-open .sidebar-search-kbd {
.sidebar.mobile-open .sidebar-search-kbd,
:root.sidebar-pinned .sidebar .sidebar-search-kbd {
opacity: 1;
}
@@ -7778,7 +7924,8 @@ label.caldav-toggle-label {
}
.sidebar.expanded .sidebar-badge,
.sidebar.pinned .sidebar-badge {
.sidebar.pinned .sidebar-badge,
:root.sidebar-pinned .sidebar .sidebar-badge {
left: auto;
right: 1rem;
top: 50%;
@@ -8764,6 +8911,44 @@ dialog.quick-add-sheet::backdrop {
background: var(--color-bg-lime-tint);
}
/* /admin/partner-units member modal — list of (display_name, role-select,
remove) rows. The role-select is wired to PATCH …/members/{user}/role
(t-paliad-143). */
.partner-unit-member-list {
list-style: none;
margin: 0 0 1rem 0;
padding: 0;
}
.partner-unit-member-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.partner-unit-member-item:last-child {
border-bottom: none;
}
.partner-unit-member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.pu-role-select {
padding: 0.25rem 0.4rem;
font-size: 0.82rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
}
.admin-team-input {
width: 100%;
padding: 0.3rem 0.45rem;
@@ -10355,3 +10540,467 @@ dialog.quick-add-sheet::backdrop {
min-width: 180px;
}
/* ============================================================================
* t-paliad-144 Phase A2 — Custom Views shell + Meine Sichten sidebar group
* + render-shape components (list / cards / calendar).
* ============================================================================ */
/* Sidebar — Meine Sichten group. Mirrors .sidebar-admin-group spacing. */
.sidebar-views-group .sidebar-views-items {
display: flex;
flex-direction: column;
}
.sidebar-views-group .sidebar-views-new {
color: var(--text-muted);
font-style: italic;
}
.sidebar-views-group .sidebar-user-view-item {
/* Inherits .sidebar-item; nothing override-worthy yet. */
}
.sidebar-user-view-badge {
/* Reuses .sidebar-badge styles; this keeps the selector available
for future tweaks. */
}
/* Views shell page — toolbar + states. */
.views-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
flex-wrap: wrap;
}
.views-toolbar-spacer {
flex: 1 1 auto;
}
.views-loading,
.views-error,
.views-empty,
.views-onboarding {
margin: 24px 0;
padding: 16px;
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
border-radius: 8px;
background: var(--surface-subtle, rgba(0,0,0,0.02));
}
.views-onboarding-actions {
margin-top: 12px;
}
.views-toast {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 0;
padding: 10px 14px;
background: #fff8db;
border: 1px solid #f3d27a;
border-radius: 6px;
color: #5b4304;
}
.views-toast-close {
background: transparent;
border: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: inherit;
}
.views-shape-host {
margin-top: 16px;
}
/* Header actions — edit / delete buttons. */
.views-header-actions {
display: flex;
gap: 8px;
}
/* shape=list (compact density). */
.views-list--compact {
list-style: none;
margin: 0;
padding: 0;
}
.views-list-row {
display: grid;
grid-template-columns: 90px 110px 1fr auto auto;
gap: 12px;
align-items: baseline;
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
font-size: 14px;
}
.views-list-row:hover {
background: var(--surface-hover, rgba(0,0,0,0.03));
}
.views-list-time {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.views-list-kind {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.views-list-title {
font-weight: 500;
}
.views-list-project,
.views-list-actor {
font-size: 13px;
color: var(--text-muted);
}
.views-list-subtitle {
grid-column: 3 / -1;
color: var(--text-muted);
font-size: 13px;
}
/* shape=cards. */
.views-cards-day {
margin-top: 24px;
}
.views-cards-day-heading {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px 0;
}
.views-cards-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.views-card {
padding: 14px;
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
border-radius: 8px;
background: var(--surface, #fff);
}
.views-card-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.views-card-kind {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.views-card-title {
font-size: 16px;
margin: 0;
}
.views-card-meta {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
}
.views-card-meta > * + *::before {
content: "·";
margin-right: 8px;
color: var(--text-muted);
}
.views-card-subtitle {
margin: 8px 0 0 0;
font-size: 13px;
color: var(--text-muted);
}
/* shape=calendar. */
.views-calendar-month-label {
font-size: 18px;
margin: 0 0 12px 0;
}
.views-calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.views-calendar-weekday {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 4px;
}
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.views-calendar-cell {
min-height: 80px;
padding: 6px;
border: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
border-radius: 4px;
background: var(--surface, #fff);
}
.views-calendar-cell--out {
background: transparent;
border: 1px dashed var(--border-subtle, rgba(0,0,0,0.04));
}
.views-calendar-cell-day {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.views-calendar-pills {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.views-calendar-pill {
font-size: 11px;
padding: 2px 4px;
border-radius: 3px;
background: var(--surface-subtle, rgba(0,0,0,0.04));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-calendar-pill--more {
color: var(--text-muted);
text-align: center;
background: transparent;
}
.views-calendar-mobile-notice {
margin: 0 0 12px 0;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
/* === Bulk team-email broadcast (t-paliad-147) === */
/* Project multi-select filter on /team. */
.team-filter-row-project {
position: relative;
display: inline-flex;
align-items: center;
margin-bottom: 8px;
}
.team-project-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
}
.team-project-summary {
font-weight: 500;
}
.team-project-panel {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
min-width: 280px;
max-width: 420px;
max-height: 360px;
overflow-y: auto;
margin-top: 4px;
padding: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
.team-project-panel.hidden {
display: none;
}
.team-project-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border);
}
.team-project-options {
display: flex;
flex-direction: column;
gap: 6px;
}
.team-project-options .filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.team-project-options .filter-checkbox:hover {
background: var(--color-bg-muted);
}
.team-broadcast-wrap {
margin: 12px 0 0 0;
}
.team-broadcast-count {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
background: rgba(255, 255, 255, 0.25);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */
.modal-broadcast {
width: 720px;
max-width: 92vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-broadcast .modal-body {
overflow-y: auto;
flex: 1;
padding: 16px 20px;
}
.modal-broadcast label {
display: block;
margin-top: 12px;
margin-bottom: 4px;
font-weight: 500;
font-size: 14px;
}
.modal-broadcast input[type="text"],
.modal-broadcast textarea,
.modal-broadcast select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-family: inherit;
font-size: 14px;
}
.modal-broadcast textarea {
resize: vertical;
min-height: 200px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
}
.broadcast-recipient-summary {
padding: 10px 12px;
background: var(--color-bg-muted);
border-radius: 4px;
font-size: 13px;
}
.broadcast-recipient-preview {
margin-top: 4px;
color: var(--color-text-muted);
}
.broadcast-recipient-list {
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 8px 12px;
}
.broadcast-recipient-list.hidden {
display: none;
}
.broadcast-recipient-list ul {
margin: 0;
padding-left: 18px;
}
.broadcast-recipient-list li {
margin: 4px 0;
font-size: 13px;
}
.broadcast-recip-email {
color: var(--color-text-muted);
font-size: 12px;
}
.broadcast-recip-role {
margin-left: 6px;
padding: 0 6px;
background: var(--color-bg-muted);
border-radius: 3px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.broadcast-hint {
margin-top: 6px;
font-size: 12px;
color: var(--color-text-muted);
}
.broadcast-error {
margin-top: 12px;
padding: 8px 10px;
background: rgba(220, 38, 38, 0.08);
color: rgb(185, 28, 28);
border-radius: 4px;
font-size: 13px;
}
.broadcast-error.hidden,
.broadcast-success.hidden {
display: none;
}
.broadcast-success {
margin-top: 12px;
padding: 8px 10px;
background: rgba(34, 197, 94, 0.1);
color: rgb(21, 128, 61);
border-radius: 4px;
font-size: 13px;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--color-link, #2563eb);
cursor: pointer;
text-decoration: underline;
font-size: inherit;
}
/* /admin/broadcasts viewer */
.broadcasts-table td {
vertical-align: top;
padding: 10px 12px;
}
.broadcast-detail-body {
margin-top: 12px;
padding: 12px 16px;
background: var(--color-bg-muted);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
white-space: pre-wrap;
}
.broadcast-detail-recipients ul {
margin: 8px 0;
padding-left: 18px;
columns: 2;
}
.broadcast-detail-recipients li {
break-inside: avoid;
font-size: 13px;
margin: 2px 0;
}
@media (max-width: 640px) {
.broadcast-detail-recipients ul {
columns: 1;
}
}

View File

@@ -68,6 +68,12 @@ export function renderTeam(): string {
<button className="filter-pill active" data-role="all" type="button" data-i18n="team.filter.all">Alle</button>
</div>
<div className="team-filter-row team-filter-row-project" id="team-project-filter" aria-label="Projekt">
</div>
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
</div>
<div className="team-list" id="team-list" />
<div className="glossar-empty" id="team-empty" style="display:none">

View File

@@ -0,0 +1,153 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Custom Views editor (t-paliad-144 Phase A2). Powers /views/new (blank
// slate) and /views/{slug}/edit (mode chosen at hydration via path
// inspection). One TSX, one bundle (client/views-editor.ts).
export function renderViewsEditor(): 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" />
<PWAHead />
<title data-i18n="views.editor.title">Sicht bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/views" />
<BottomNav currentPath="/views" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 id="editor-heading" data-i18n="views.editor.heading.new">Neue Sicht</h1>
<p className="tool-subtitle" data-i18n="views.editor.subtitle">
W&auml;hlen Sie Quellen, Filter und Darstellung. &Auml;nderungen speichern Sie unten.
</p>
</div>
<form id="editor-form" className="entity-form" novalidate>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.identity">Bezeichnung</legend>
<div className="form-field">
<label htmlFor="editor-name" data-i18n="views.editor.field.name">Name</label>
<input id="editor-name" type="text" required maxlength={200} />
</div>
<div className="form-field">
<label htmlFor="editor-slug" data-i18n="views.editor.field.slug">Slug (URL)</label>
<input id="editor-slug" type="text" required pattern="^[a-z0-9][a-z0-9-]{0,62}$" maxlength={63} />
<small className="form-hint" data-i18n="views.editor.hint.slug">
Kleinbuchstaben, Ziffern und Bindestriche &mdash; nicht reservierte W&ouml;rter.
</small>
</div>
<div className="form-field">
<label htmlFor="editor-icon" data-i18n="views.editor.field.icon">Icon</label>
<select id="editor-icon">
<option value="" data-i18n="views.editor.icon.default">Standard (Ordner)</option>
<option value="clock" data-i18n="views.editor.icon.clock">Uhr</option>
<option value="calendar" data-i18n="views.editor.icon.calendar">Kalender</option>
<option value="bell" data-i18n="views.editor.icon.bell">Glocke</option>
<option value="folder" data-i18n="views.editor.icon.folder">Ordner</option>
<option value="users" data-i18n="views.editor.icon.users">Personen</option>
<option value="building" data-i18n="views.editor.icon.building">Geb&auml;ude</option>
</select>
</div>
<div className="form-field form-field-checkbox">
<label>
<input id="editor-show-count" type="checkbox" />
<span data-i18n="views.editor.field.show_count">Treffer-Anzahl in der Sidebar anzeigen</span>
</label>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.sources">Quellen</legend>
<p className="form-hint" data-i18n="views.editor.hint.sources">Welche Datenarten zeigt diese Sicht?</p>
<div className="form-field form-field-checkbox-group">
<label><input type="checkbox" name="source" value="deadline" /> <span data-i18n="views.source.deadline">Fristen</span></label>
<label><input type="checkbox" name="source" value="appointment" /> <span data-i18n="views.source.appointment">Termine</span></label>
<label><input type="checkbox" name="source" value="project_event" /> <span data-i18n="views.source.project_event">Projekt-Verlauf</span></label>
<label><input type="checkbox" name="source" value="approval_request" /> <span data-i18n="views.source.approval_request">Genehmigungen</span></label>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.scope">Geltungsbereich</legend>
<div className="form-field">
<label htmlFor="editor-scope-mode" data-i18n="views.editor.field.scope_mode">Projekte</label>
<select id="editor-scope-mode">
<option value="all_visible" data-i18n="views.scope.all_visible">Alle sichtbaren</option>
<option value="my_subtree" data-i18n="views.scope.my_subtree">Mein Teilbaum</option>
</select>
</div>
<div className="form-field form-field-checkbox">
<label>
<input id="editor-personal-only" type="checkbox" />
<span data-i18n="views.editor.field.personal_only">Nur pers&ouml;nliche</span>
</label>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.time">Zeitraum</legend>
<div className="form-field">
<label htmlFor="editor-time-horizon" data-i18n="views.editor.field.horizon">Horizont</label>
<select id="editor-time-horizon">
<option value="next_7d" data-i18n="views.horizon.next_7d">N&auml;chste 7 Tage</option>
<option value="next_30d" data-i18n="views.horizon.next_30d">N&auml;chste 30 Tage</option>
<option value="next_90d" data-i18n="views.horizon.next_90d">N&auml;chste 90 Tage</option>
<option value="past_30d" data-i18n="views.horizon.past_30d">Letzte 30 Tage</option>
<option value="past_90d" data-i18n="views.horizon.past_90d">Letzte 90 Tage</option>
<option value="any" data-i18n="views.horizon.any">Beliebig</option>
</select>
</div>
</fieldset>
<fieldset className="form-section">
<legend data-i18n="views.editor.section.render">Darstellung</legend>
<div className="form-field">
<label htmlFor="editor-shape" data-i18n="views.editor.field.shape">Form</label>
<select id="editor-shape">
<option value="list" data-i18n="views.shape.list">Liste</option>
<option value="cards" data-i18n="views.shape.cards">Karten</option>
<option value="calendar" data-i18n="views.shape.calendar">Kalender</option>
</select>
</div>
<div className="form-field" id="editor-list-density-group">
<label htmlFor="editor-list-density" data-i18n="views.editor.field.density">Dichte</label>
<select id="editor-list-density">
<option value="comfortable" data-i18n="views.density.comfortable">Bequem</option>
<option value="compact" data-i18n="views.density.compact">Kompakt</option>
</select>
</div>
</fieldset>
<div className="entity-form-feedback" id="editor-feedback" hidden />
<div className="entity-form-actions">
<button type="submit" className="btn-primary btn-cta-lime" id="editor-save" data-i18n="views.editor.save">
Speichern
</button>
<a href="/views" className="btn-secondary" data-i18n="views.editor.cancel">Abbrechen</a>
<button type="button" className="btn-danger" id="editor-delete" hidden data-i18n="views.editor.delete">
L&ouml;schen
</button>
</div>
</form>
</div>
</section>
<Footer />
</main>
<script src="/assets/views-editor.js" defer />
</body>
</html>
);
}

105
frontend/src/views.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Custom Views shell (t-paliad-144 Phase A2). One TSX powers /views (the
// landing) and /views/{slug} (a specific view). The client bundle reads
// window.location.pathname to decide which mode to render.
//
// Hydration: client/views.ts loads the saved or system view via /api/views
// and dispatches to the matching render-shape component (list / cards /
// calendar — Q4 lock-in 2026-05-07: 3 shapes, no separate "activity").
export function renderViews(): 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" />
<PWAHead />
<title data-i18n="views.title">Sichten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/views" />
<BottomNav currentPath="/views" />
<main>
<section className="tool-page">
<div className="container">
{/* Header — populated by client/views.ts from the loaded view name. */}
<div className="tool-header" id="views-header">
<div className="entity-header-row">
<div>
<h1 id="views-heading" data-i18n="views.heading">Sichten</h1>
<p className="tool-subtitle" id="views-subtitle" data-i18n="views.subtitle">
Eigene Sichten &uuml;ber Ihre Daten &mdash; Filter und Darstellung speicherbar.
</p>
</div>
<div className="views-header-actions" id="views-header-actions">
{/* Edit + delete buttons inserted by client/views.ts when on a custom view. */}
</div>
</div>
</div>
{/* Toolbar — shape switcher (3 shapes per Q4 lock-in). */}
<div className="views-toolbar" id="views-toolbar" hidden>
<div className="agenda-chip-row" role="tablist" id="views-shape-chips" aria-label="Form">
<button type="button" className="agenda-chip" data-shape="list" role="tab" data-i18n="views.shape.list">Liste</button>
<button type="button" className="agenda-chip" data-shape="cards" role="tab" data-i18n="views.shape.cards">Karten</button>
<button type="button" className="agenda-chip" data-shape="calendar" role="tab" data-i18n="views.shape.calendar">Kalender</button>
</div>
<div className="views-toolbar-spacer" />
<a href="#" className="btn-secondary btn-small" id="views-save-as" data-i18n="views.save_as" hidden>
Als Sicht speichern
</a>
</div>
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
<div className="views-onboarding" id="views-onboarding" hidden>
<h2 data-i18n="views.onboarding.title">Eigene Sichten &mdash; was ist das?</h2>
<p data-i18n="views.onboarding.body">
Eine Sicht ist eine gespeicherte Filterkombination &mdash; z.&thinsp;B. &bdquo;Fristen meiner Projekte in den n&auml;chsten 14 Tagen&ldquo;.
Sichten erscheinen als eigene Buttons in der Sidebar.
</p>
<div className="views-onboarding-actions">
<a href="/views/new" className="btn-primary btn-cta-lime" data-i18n="views.onboarding.create">
Beispiel-Sicht erstellen
</a>
</div>
</div>
{/* Inaccessible-projects toast (Q17 attribution). */}
<div className="views-toast" id="views-toast" hidden>
<span className="views-toast-text" id="views-toast-text" />
<button type="button" className="views-toast-close" id="views-toast-close" aria-label="Close">&times;</button>
</div>
{/* Loading + error + empty states (mutually exclusive). */}
<div className="views-loading" id="views-loading" data-i18n="views.loading">L&auml;dt &hellip;</div>
<div className="views-error" id="views-error" hidden>
<p id="views-error-message" />
<a href="/views" className="btn-secondary btn-small" data-i18n="views.error.back">Zur&uuml;ck zur Sichten-&Uuml;bersicht</a>
</div>
<div className="views-empty" id="views-empty" hidden>
<p data-i18n="views.empty.title">Keine Eintr&auml;ge gefunden.</p>
<p className="views-empty-hint" id="views-empty-hint" />
</div>
{/* Render targets — only the active shape is visible. */}
<div className="views-shape-host views-shape-list" id="views-shape-list" hidden />
<div className="views-shape-host views-shape-cards" id="views-shape-cards" hidden />
<div className="views-shape-host views-shape-calendar" id="views-shape-calendar" hidden />
</div>
</section>
<Footer />
</main>
<script src="/assets/views.js" defer />
</body>
</html>
);
}

View File

@@ -0,0 +1,53 @@
-- Down migration for t-paliad-139 (055_hierarchy_aggregation).
--
-- Reverses the schema additions in lockstep with the up migration:
-- 1. Restore can_see_project to the migration-023 body (drop derivation
-- branch).
-- 2. Drop paliad.approval_role_from_unit_role helper.
-- 3. Drop paliad.project_partner_units (cascades the policies + index).
-- 4. Drop paliad.partner_unit_members.unit_role.
--
-- If any project has project_partner_units rows with derive_grants_authority=true
-- AND any approval_request was ever signed using a derived_peer decision_kind
-- (t-paliad-139 Phase 3), the down does NOT roll those back — the audit rows
-- stay valid; only the schema is reverted. Down is intentionally lossy on
-- in-flight derivation state.
-- Restore the migration-054 decision_kind CHECK (without 'derived_peer').
-- Any existing rows with decision_kind='derived_peer' would fail the
-- restored CHECK; the down deliberately doesn't update them — operators
-- must reconcile before applying the down migration.
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check
CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override'));
-- 1. Restore migration-023 can_see_project body (no derivation branch).
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
-- 2. Drop the unit_role → project_role mapping helper.
DROP FUNCTION IF EXISTS paliad.approval_role_from_unit_role(text);
-- 3. Drop the project↔unit junction (CASCADE clears policies + index).
DROP TABLE IF EXISTS paliad.project_partner_units;
-- 4. Drop the unit_role column.
ALTER TABLE paliad.partner_unit_members DROP COLUMN IF EXISTS unit_role;

View File

@@ -0,0 +1,174 @@
-- t-paliad-139: hierarchy aggregation — partner-unit derivation schema.
--
-- Design: docs/design-hierarchy-aggregation-2026-05-06.md (noether, m-locked 2026-05-06).
--
-- This is the Phase 2 schema migration. Day-1 deploy = zero behaviour change
-- because:
-- - Every existing partner_unit_members row defaults to unit_role='attorney'.
-- - The default derive_unit_roles on the new junction is {'pa','senior_pa'}.
-- - No project_partner_units rows exist yet; admins opt-in by attaching
-- units to projects.
-- Until those two conditions diverge, no derivation happens and visibility
-- behaves identically to the pre-055 world.
--
-- Sections:
-- 1. ALTER paliad.partner_unit_members ADD COLUMN unit_role.
-- 2. CREATE paliad.project_partner_units junction (with RLS).
-- 3. CREATE paliad.approval_role_from_unit_role helper.
-- 4. CREATE OR REPLACE paliad.can_see_project — extended with derivation
-- branch.
-- ============================================================================
-- 1. unit_role on paliad.partner_unit_members.
--
-- Per-unit role distinction so derivation can target specific tiers (default
-- {pa, senior_pa}) without re-introducing a firm-wide rank column. The same
-- user can have a different unit_role in different units; in practice most
-- users belong to one unit so this is effectively a firm-rank, but the per-
-- unit framing preserves the t-paliad-051/-138 three-axis principle on the
-- user side (job_title remains free-text display, global_role stays
-- standard|global_admin).
-- ============================================================================
ALTER TABLE paliad.partner_unit_members
ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney'
CHECK (unit_role IN ('lead', 'attorney', 'senior_pa', 'pa', 'paralegal'));
-- ============================================================================
-- 2. paliad.project_partner_units — project ↔ unit involvement.
--
-- A row here means "this unit is involved on this project, and the listed
-- unit_roles auto-derive onto the project team". Authority defaults to off
-- (visibility-only): set derive_grants_authority=true to let derived members
-- count as approvers (per t-paliad-139 §3.4). Composite PK enforces "one
-- attachment per (project, unit)".
-- ============================================================================
CREATE TABLE paliad.project_partner_units (
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
-- Roles in the unit that auto-derive onto the project team. Defaults
-- target PAs only; a project can widen to ['pa','senior_pa','attorney']
-- to pull the whole unit, or narrow to ['pa'] to exclude senior_pa.
derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa', 'senior_pa'],
-- Strict default: derived members are visibility-only. Flipping this on
-- lets them be eligible approvers per the t-138 ladder via the mapping
-- in paliad.approval_role_from_unit_role.
derive_grants_authority boolean NOT NULL DEFAULT false,
attached_at timestamptz NOT NULL DEFAULT now(),
attached_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
PRIMARY KEY (project_id, partner_unit_id)
);
CREATE INDEX project_partner_units_unit_idx
ON paliad.project_partner_units (partner_unit_id, project_id);
ALTER TABLE paliad.project_partner_units ENABLE ROW LEVEL SECURITY;
-- Anyone who can see the project can see the unit attachment. Mirrors the
-- approval_requests / deadlines / appointments policy.
CREATE POLICY project_partner_units_select
ON paliad.project_partner_units FOR SELECT
USING (paliad.can_see_project(project_id));
-- Writes gated to global_admin OR project lead. Same pattern as
-- /admin/team and /admin/partner-units precedent.
CREATE POLICY project_partner_units_write
ON paliad.project_partner_units FOR ALL
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.role = 'lead')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.role = 'lead')
);
-- ============================================================================
-- 3. paliad.approval_role_from_unit_role — unit_role → project_role mapping.
--
-- Used when a derived member's authority is evaluated by the t-138 strict
-- ladder. The mapping is intentional:
-- lead → lead (the unit's lead, matches project lead tier)
-- attorney → associate (default for working lawyers)
-- senior_pa → senior_pa (1:1)
-- pa → pa (1:1)
-- paralegal → observer (level 0 — ineligible to approve)
-- The ApprovalService (t-138) reads project_teams.role first; only when that
-- has no row does it fall back to derived authority via this mapping (and
-- only when the project_partner_units row has derive_grants_authority=true).
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE unit_role
WHEN 'lead' THEN 'lead'
WHEN 'attorney' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
ELSE 'observer'
END
$$;
-- ============================================================================
-- 4. Extend paliad.approval_requests.decision_kind CHECK to allow
-- 'derived_peer' — a derived (partner-unit) member with authority who
-- signed off via the t-paliad-138 inbox path. Distinct from plain
-- 'peer' so the audit trail discloses the derivation chain.
-- ============================================================================
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check
CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override', 'derived_peer'));
-- ============================================================================
-- 5. paliad.can_see_project — extended with derivation branch.
--
-- Same shape as the migration-023 body, plus one EXISTS branch: a user is
-- visible on a project if there is any (ancestor of project) attached to a
-- partner_unit they are a member of, AND their unit_role is in the derive
-- set for that attachment. Read-cost is small (project_partner_units +
-- partner_unit_members are tiny).
--
-- t-paliad-139 §3.3 Option B: compute on read, no materialised state.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = paliad, public
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = auth.uid()
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_partner_units ppu
ON ppu.project_id = ANY(string_to_array(target.path, '.')::uuid[])
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = auth.uid()
AND pum.unit_role = ANY(ppu.derive_unit_roles)
WHERE target.id = _project_id
);
$$;

View File

@@ -0,0 +1,3 @@
-- Reverse of 056_user_views.up.sql.
DROP TABLE IF EXISTS paliad.user_views;

View File

@@ -0,0 +1,77 @@
-- t-paliad-144 Phase A1: Custom Views — paliad.user_views.
--
-- Design: docs/design-data-display-model-2026-05-06.md (noether,
-- m-locked 2026-05-07).
--
-- Stores per-user saved view definitions. A view is a `(filter_spec,
-- render_spec, sidebar metadata)` tuple. RLS scopes every operation
-- to the calling user — there is no cross-user visibility in v1.
--
-- System defaults (dashboard / agenda / events / inbox) stay code-
-- resident in internal/services/system_views.go. They never appear
-- as rows in this table; the slugs are reserved and rejected at write
-- time by the application layer.
--
-- Sections:
-- 1. CREATE paliad.user_views (with RLS).
-- 2. Indexes.
-- ============================================================================
-- 1. paliad.user_views
-- ============================================================================
CREATE TABLE paliad.user_views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
-- Stable user-facing identifier. Goes into the URL.
-- Application-layer validator enforces ^[a-z0-9][a-z0-9-]{0,62}$ +
-- a reserved-list rejection (dashboard, agenda, events, inbox, …).
slug text NOT NULL,
-- Display name. Free-form; user picks the language they think in.
-- Rendered verbatim in the sidebar; no fallback or translation.
name text NOT NULL,
-- One of a fixed set of icon keys (see Sidebar.tsx icon registry).
-- NULL → default icon (folder). Validator caps length to keep the
-- column sane even if the registry is bypassed.
icon text,
-- Filter spec — see internal/services/filter_spec.go FilterSpec.
-- Validated on write; jsonb here for forward-compat without
-- migrations as new dimensions land.
filter_spec jsonb NOT NULL,
-- Render spec — see internal/services/render_spec.go RenderSpec.
render_spec jsonb NOT NULL,
-- Sidebar ordering. Lower-first. New views land at MAX+1 server-side
-- so they sort to the bottom; the editor lets users drag-reorder.
sort_order integer NOT NULL DEFAULT 0,
-- Show a row-count badge on the sidebar entry. Costs one COUNT(*)
-- per refresh; opt-in (default false) so casual users don't pay.
show_count boolean NOT NULL DEFAULT false,
-- Most-recently-used landing on /views (Q10). Updated by a fire-
-- and-forget PATCH on every view-load.
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, slug)
);
CREATE INDEX user_views_owner_idx
ON paliad.user_views (user_id, sort_order);
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
-- Owner-only access. No global_admin override: views are personal
-- working state, not auditable infrastructure.
CREATE POLICY user_views_owner_all
ON paliad.user_views FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());

View File

@@ -0,0 +1,3 @@
-- Reverse of 057_email_broadcasts.up.sql.
DROP TABLE IF EXISTS paliad.email_broadcasts;

View File

@@ -0,0 +1,91 @@
-- t-paliad-147: Bulk team email — paliad.email_broadcasts.
--
-- Records every bulk-send sent from /team's "E-Mail an Auswahl" flow.
-- Powers the /admin/broadcasts viewer (global_admin sees all rows;
-- senders see their own).
--
-- recipient_filter snapshots the filter chips the sender had selected
-- (project_ids, offices, roles) so a future deploy that tweaks the
-- filter UX can still render past sends. recipient_user_ids snapshots
-- the resolved user list — the actual addressees, immune to later
-- team-membership changes.
--
-- Sections:
-- 1. CREATE paliad.email_broadcasts.
-- 2. Indexes.
-- 3. RLS.
-- ============================================================================
-- 1. paliad.email_broadcasts
-- ============================================================================
CREATE TABLE paliad.email_broadcasts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Renderable subject (post-template). Stored verbatim for audit.
subject text NOT NULL,
-- Body source as the sender typed it (Markdown). NOT the per-recipient
-- rendered output — those are reconstructable by re-rendering with the
-- snapshotted recipient row, but the source is what we audit.
body text NOT NULL,
-- The sender. FK to paliad.users (not auth.users) so deleting an auth
-- row leaves the audit trail intact via paliad.users.
sender_id uuid NOT NULL REFERENCES paliad.users(id),
-- Optional template the sender started from. NULL when freeform.
template_key text,
-- Snapshot of filter chips selected at send time. Keys: project_ids
-- (uuid[]), offices (text[]), roles (text[]). jsonb for forward-compat.
recipient_filter jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Resolved addressee list — the user_ids that received (or attempted)
-- the mail. Immune to subsequent team-membership changes.
recipient_user_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
-- Per-send result counts (sent, failed, total). jsonb so we can grow
-- the report shape without a migration.
send_report jsonb NOT NULL DEFAULT '{}'::jsonb,
sent_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
-- ============================================================================
-- 2. Indexes
-- ============================================================================
CREATE INDEX email_broadcasts_sent_at_idx
ON paliad.email_broadcasts (sent_at DESC);
CREATE INDEX email_broadcasts_sender_idx
ON paliad.email_broadcasts (sender_id, sent_at DESC);
-- ============================================================================
-- 3. RLS
-- ============================================================================
ALTER TABLE paliad.email_broadcasts ENABLE ROW LEVEL SECURITY;
-- Senders can read their own rows; global_admin can read everything.
-- The Go service layer (BroadcastService) is the load-bearing gate; RLS
-- here is defence-in-depth for any future auth-context query path.
CREATE POLICY email_broadcasts_select
ON paliad.email_broadcasts FOR SELECT
USING (
sender_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
);
-- Inserts only by the sender themselves (defence-in-depth — the service
-- enforces project_lead-OR-global_admin authorship; RLS only enforces the
-- self-attribution bit).
CREATE POLICY email_broadcasts_insert
ON paliad.email_broadcasts FOR INSERT
WITH CHECK (sender_id = auth.uid());

View File

@@ -108,7 +108,8 @@ func handleListAppointmentsForProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID)
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID, directOnly)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -0,0 +1,197 @@
// broadcasts.go — bulk team-email send (t-paliad-147 / issue #7).
//
// One write endpoint (/api/team/broadcast) and a pair of read endpoints
// for the /admin/broadcasts viewer.
//
// The /api/team/broadcast handler enforces the project-lead-OR-global_admin
// authorisation in BroadcastService.Send, so non-leads receive 403.
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// broadcastRequest is the JSON body for POST /api/team/broadcast.
//
// Recipients carry the addresseelist as resolved on the client side: the
// frontend filters the displayed team table, then submits the user_ids the
// user wanted to mail. The server validates each address and rejects if
// any is malformed.
type broadcastRequest struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
TemplateKey string `json:"template_key,omitempty"`
Lang string `json:"lang,omitempty"`
RecipientFilter map[string]any `json:"recipient_filter,omitempty"`
Recipients []broadcastRequestRecipient `json:"recipients"`
}
type broadcastRequestRecipient struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
FirstName string `json:"first_name"`
RoleOnProject string `json:"role_on_project"`
}
// POST /api/team/broadcast — dispatch a personalised email to a filtered
// team subset. Returns the broadcast ID and per-recipient send report.
func handleTeamBroadcast(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.broadcast == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "broadcasts unavailable — broadcast service not configured",
})
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var req broadcastRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
in := services.BroadcastInput{
ProjectID: req.ProjectID,
Subject: req.Subject,
Body: req.Body,
TemplateKey: req.TemplateKey,
Lang: req.Lang,
RecipientFilter: req.RecipientFilter,
Recipients: make([]services.BroadcastRecipient, 0, len(req.Recipients)),
}
for _, rc := range req.Recipients {
in.Recipients = append(in.Recipients, services.BroadcastRecipient{
UserID: rc.UserID,
Email: rc.Email,
DisplayName: rc.DisplayName,
FirstName: rc.FirstName,
RoleOnProject: rc.RoleOnProject,
})
}
report, err := dbSvc.broadcast.Send(r.Context(), uid, in)
if err != nil {
switch {
case errors.Is(err, services.ErrBroadcastForbidden):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "only project leads or global admins can send broadcasts",
})
case errors.Is(err, services.ErrBroadcastNoRecipients):
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "no recipients selected",
})
case errors.Is(err, services.ErrBroadcastTooManyRecipients):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
"error": err.Error(),
})
case errors.Is(err, services.ErrBroadcastEmptySubject):
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "subject is required",
})
case errors.Is(err, services.ErrBroadcastEmptyBody):
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "body is required",
})
case errors.Is(err, services.ErrBroadcastInvalidEmail):
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to send broadcast",
})
}
return
}
writeJSON(w, http.StatusCreated, report)
}
// GET /api/admin/broadcasts — list broadcasts visible to the caller.
// global_admin sees all rows; senders see their own.
//
// Lives behind the gateOnboarded gate (not adminGate) so a project lead
// who's never been promoted to global_admin can still see their own
// sends.
func handleListBroadcasts(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.broadcast == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "broadcasts unavailable",
})
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
limit = parsed
}
}
rows, err := dbSvc.broadcast.List(r.Context(), uid, limit)
if err != nil {
if errors.Is(err, services.ErrBroadcastForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/admin/broadcasts/{id} — full detail for one broadcast.
func handleGetBroadcast(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.broadcast == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "broadcasts unavailable",
})
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
}
detail, err := dbSvc.broadcast.Get(r.Context(), uid, id)
if err != nil {
if errors.Is(err, services.ErrBroadcastForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, detail)
}
// GET /admin/broadcasts — server-rendered shell.
func handleAdminBroadcastsPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-broadcasts.html")
}

View File

@@ -128,7 +128,8 @@ func handleListDeadlinesForProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID)
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID, directOnly)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -0,0 +1,189 @@
package handlers
// HTTP handlers for partner-unit derivation (t-paliad-139 Phase 2).
//
// Endpoints:
// GET /api/projects/{id}/partner-units → list attached units
// POST /api/projects/{id}/partner-units → attach (or update opts)
// DELETE /api/projects/{id}/partner-units/{unit_id} → detach
// GET /api/projects/{id}/team/derived → list derived members
// GET /api/projects/{id}/team/from-descendants → list descendant-staffed
// PATCH /api/partner-units/{id}/members/{user_id}/role → set unit_role on a member
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/projects/{id}/partner-units
func handleListAttachedUnits(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.derivation.ListAttachedUnits(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/projects/{id}/partner-units
//
// Body: { partner_unit_id, derive_unit_roles[]?, derive_grants_authority? }.
// Idempotent on (project_id, partner_unit_id) — repeat calls update opts.
func handleAttachPartnerUnit(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
PartnerUnitID string `json:"partner_unit_id"`
DeriveUnitRoles []string `json:"derive_unit_roles"`
DeriveGrantsAuthority bool `json:"derive_grants_authority"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
unitID, err := uuid.Parse(body.PartnerUnitID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid partner_unit_id"})
return
}
if err := dbSvc.derivation.AttachUnitToProject(r.Context(), uid, projectID, unitID, services.AttachUnitOptions{
DeriveUnitRoles: body.DeriveUnitRoles,
DeriveGrantsAuthority: body.DeriveGrantsAuthority,
}); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// DELETE /api/projects/{id}/partner-units/{unit_id}
func handleDetachPartnerUnit(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit_id"})
return
}
if err := dbSvc.derivation.DetachUnitFromProject(r.Context(), uid, projectID, unitID); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
// GET /api/projects/{id}/team/derived
func handleListDerivedTeam(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.derivation.ListDerivedMembers(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/projects/{id}/team/from-descendants
func handleListDescendantStaffedTeam(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, err := dbSvc.derivation.ListDescendantStaffed(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// PATCH /api/partner-units/{id}/members/{user_id}/role
//
// Body: { unit_role: 'lead'|'attorney'|'senior_pa'|'pa'|'paralegal' }.
// Admin-only (gated by PartnerUnitService.SetMemberRole's requireAdmin).
func handleSetUnitMemberRole(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
userID, err := uuid.Parse(r.PathValue("user_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user_id"})
return
}
var body struct {
UnitRole string `json:"unit_role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if err := dbSvc.partnerUnit.SetMemberRole(r.Context(), uid, unitID, userID, body.UnitRole); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

View File

@@ -63,6 +63,9 @@ type Services struct {
Event *services.EventService
Courts *services.CourtService
Approval *services.ApprovalService
Derivation *services.DerivationService
UserView *services.UserViewService
Broadcast *services.BroadcastService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
@@ -98,6 +101,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
event: svc.Event,
courts: svc.Courts,
approval: svc.Approval,
derivation: svc.Derivation,
userView: svc.UserView,
broadcast: svc.Broadcast,
}
}
@@ -202,6 +208,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
// t-paliad-139 — sub-team aggregation surfaces for the Team tab.
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)
protected.HandleFunc("GET /api/projects/{id}/team/from-descendants", handleListDescendantStaffedTeam)
// t-paliad-139 — project ↔ partner-unit attachment management.
protected.HandleFunc("GET /api/projects/{id}/partner-units", handleListAttachedUnits)
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
@@ -212,6 +225,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/partner-units/{id}/members", handleListPartnerUnitMembers)
protected.HandleFunc("POST /api/partner-units/{id}/members", handleAddPartnerUnitMember)
protected.HandleFunc("DELETE /api/partner-units/{id}/members/{user_id}", handleRemovePartnerUnitMember)
// t-paliad-139 — set unit_role on a member.
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
@@ -328,6 +343,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// Team directory — browsable list of all onboarded users (t-paliad-029).
protected.HandleFunc("GET /team", gateOnboarded(handleTeamPage))
// t-paliad-147 — bulk team-email broadcast.
// /api/team/broadcast: project lead OR global_admin → BroadcastService gates.
// /admin/broadcasts page + list/detail API: visibility-gated in service
// (global_admin sees all; sender sees own).
protected.HandleFunc("GET /api/team/memberships", gateOnboarded(handleListMembershipsIndex))
protected.HandleFunc("POST /api/team/broadcast", gateOnboarded(handleTeamBroadcast))
protected.HandleFunc("GET /admin/broadcasts", gateOnboarded(handleAdminBroadcastsPage))
protected.HandleFunc("GET /api/admin/broadcasts", gateOnboarded(handleListBroadcasts))
protected.HandleFunc("GET /api/admin/broadcasts/{id}", gateOnboarded(handleGetBroadcast))
// Settings
protected.HandleFunc("GET /settings", gateOnboarded(handleSettingsPage))
protected.HandleFunc("GET /settings/{tab}", handleSettingsTabRedirect)
@@ -392,6 +417,30 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
}
// t-paliad-144 Phase A1+A2 — Custom Views (substrate + user_views CRUD
// + page shells). API endpoints register when the substrate services are
// wired; page shells register unconditionally so /views itself stays
// reachable for the empty-state onboarding.
if svc != nil && svc.UserView != nil && svc.Event != nil {
// API
protected.HandleFunc("GET /api/user-views", handleListUserViews)
protected.HandleFunc("POST /api/user-views", handleCreateUserView)
protected.HandleFunc("GET /api/user-views/{id}", handleGetUserView)
protected.HandleFunc("PATCH /api/user-views/{id}", handleUpdateUserView)
protected.HandleFunc("DELETE /api/user-views/{id}", handleDeleteUserView)
protected.HandleFunc("POST /api/user-views/{id}/touch", handleTouchUserView)
protected.HandleFunc("POST /api/views/run", handleRunAdhocView)
protected.HandleFunc("POST /api/views/{slug}/run", handleRunSavedView)
protected.HandleFunc("GET /api/views/system", handleListSystemViews)
// Page shells (A2)
protected.HandleFunc("GET /views", gateOnboarded(handleViewsLandingPage))
protected.HandleFunc("GET /views/new", gateOnboarded(handleViewsNewPage))
protected.HandleFunc("GET /views/{slug}/edit", gateOnboarded(handleViewsEditPage))
protected.HandleFunc("GET /views/{slug}", gateOnboarded(handleViewsShellPage))
}
// Catch-all 404 — runs for any authenticated path that no more-specific
// pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from
// tests/smoke-auth-2026-04-25.md). Must be registered last on this mux.
@@ -415,3 +464,15 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// parseDirectOnly reads a `direct_only=true|false` query value. Returns true
// only for the explicit "true" / "1" forms; everything else (including empty)
// is the subtree-aggregating default per t-paliad-139.
func parseDirectOnly(raw string) bool {
switch raw {
case "true", "1":
return true
default:
return false
}
}

View File

@@ -43,6 +43,9 @@ type dbServices struct {
event *services.EventService
courts *services.CourtService
approval *services.ApprovalService
derivation *services.DerivationService
userView *services.UserViewService
broadcast *services.BroadcastService
}
var dbSvc *dbServices
@@ -383,7 +386,8 @@ func handleListProjectEvents(w http.ResponseWriter, r *http.Request) {
}
limit = n
}
rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit)
directOnly := parseDirectOnly(q.Get("direct_only"))
rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit, directOnly)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -63,6 +63,26 @@ func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, m)
}
// GET /api/team/memberships — bulk index of project_teams membership for
// every (visible) user × project pair. Powers the /team page project-
// multi-select filter (t-paliad-147 / issue #7). Cheap to call: one
// scan per call; client-side filter handles everything from there.
func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.team.ListMembershipsIndex(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
// Inherited memberships can't be removed at the child level.
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {

393
internal/handlers/views.go Normal file
View File

@@ -0,0 +1,393 @@
package handlers
// HTTP handlers for the Custom Views feature (t-paliad-144 Phase A1).
//
// Endpoints:
// GET /api/user-views — list saved views
// POST /api/user-views — create
// GET /api/user-views/{id} — fetch one
// PATCH /api/user-views/{id} — partial update
// DELETE /api/user-views/{id} — delete
// POST /api/user-views/{id}/touch — bump last_used_at
//
// POST /api/views/run — run an ad-hoc spec
// POST /api/views/{slug}/run — run a saved view by slug
// GET /api/views/system — list system view definitions
//
// All endpoints require authentication. Paliad's RLS scopes user_views
// rows to auth.uid(); the handler layer also AND-joins userID for
// defense-in-depth.
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// requireUserViews returns true when the user-view + substrate services
// are wired. Calls writeJSON 503 + returns false otherwise.
func requireUserViews(w http.ResponseWriter) bool {
if !requireDB(w) {
return false
}
if dbSvc.userView == nil || dbSvc.event == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "views not configured",
})
return false
}
return true
}
// ============================================================================
// /api/user-views — CRUD
// ============================================================================
// GET /api/user-views — list the caller's saved views.
func handleListUserViews(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
views, err := dbSvc.userView.ListForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, views)
}
// userViewCreatePayload mirrors services.CreateUserViewInput on the wire.
type userViewCreatePayload struct {
Slug string `json:"slug"`
Name string `json:"name"`
Icon *string `json:"icon,omitempty"`
FilterSpec services.FilterSpec `json:"filter_spec"`
RenderSpec services.RenderSpec `json:"render_spec"`
ShowCount bool `json:"show_count,omitempty"`
}
// POST /api/user-views — create.
func handleCreateUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var p userViewCreatePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
created, err := dbSvc.userView.Create(r.Context(), uid, services.CreateUserViewInput{
Slug: p.Slug,
Name: p.Name,
Icon: p.Icon,
FilterSpec: p.FilterSpec,
RenderSpec: p.RenderSpec,
ShowCount: p.ShowCount,
})
if err != nil {
writeUserViewError(w, err)
return
}
writeJSON(w, http.StatusCreated, created)
}
// GET /api/user-views/{id} — fetch one.
func handleGetUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(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
}
view, err := dbSvc.userView.GetByID(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
if view == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, view)
}
// userViewUpdatePayload accepts every field as optional. `null` icon
// clears the field (matching service-side semantic of *string{""} → clear).
type userViewUpdatePayload struct {
Slug *string `json:"slug,omitempty"`
Name *string `json:"name,omitempty"`
Icon *string `json:"icon,omitempty"`
FilterSpec *services.FilterSpec `json:"filter_spec,omitempty"`
RenderSpec *services.RenderSpec `json:"render_spec,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
ShowCount *bool `json:"show_count,omitempty"`
}
// PATCH /api/user-views/{id} — partial update.
func handleUpdateUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(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
}
var p userViewUpdatePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
updated, err := dbSvc.userView.Update(r.Context(), uid, id, services.UpdateUserViewInput{
Slug: p.Slug,
Name: p.Name,
Icon: p.Icon,
FilterSpec: p.FilterSpec,
RenderSpec: p.RenderSpec,
SortOrder: p.SortOrder,
ShowCount: p.ShowCount,
})
if err != nil {
writeUserViewError(w, err)
return
}
if updated == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, updated)
}
// DELETE /api/user-views/{id} — delete.
func handleDeleteUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(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
}
deleted, err := dbSvc.userView.Delete(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
if !deleted {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/user-views/{id}/touch — bump last_used_at. Fire-and-forget
// from the page handler (Q10 most-recently-used landing).
func handleTouchUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(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.userView.Touch(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================================
// /api/views — substrate execution
// ============================================================================
// runRequest wraps the optional spec override for /api/views/{slug}/run.
// When body is empty / fields are zero-valued, the saved spec is used as-is.
type runRequest struct {
Filter *services.FilterSpec `json:"filter,omitempty"`
Render *services.RenderSpec `json:"render,omitempty"` // currently informational; substrate ignores
}
// POST /api/views/run — execute an ad-hoc FilterSpec without persisting.
//
// Used by the editor's live-preview (Q27) and by the inbox/agenda
// system pages internally (Phase B will route them here; Phase A1
// leaves the wiring as a no-op for those pages).
func handleRunAdhocView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var p runRequest
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
if p.Filter == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filter is required"})
return
}
if err := p.Filter.Validate(); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, *p.Filter, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// POST /api/views/{slug}/run — run a saved view (or a system view by slug).
//
// Optional body: { filter: <override> } overrides the saved spec for
// this run only (transient — doesn't mutate the stored row). Used for
// query-param overrides in the URL contract (Q16).
func handleRunSavedView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
if slug == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug is required"})
return
}
// System view first — code-resident; doesn't need DB read.
if sys := lookupSystemView(slug); sys != nil {
spec := sys.Filter
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
return
}
// User view.
view, err := dbSvc.userView.GetBySlug(r.Context(), uid, slug)
if err != nil {
writeServiceError(w, err)
return
}
if view == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "view not found"})
return
}
spec, err := services.UnmarshalFilterSpec(view.FilterSpec)
if err != nil {
writeServiceError(w, err)
return
}
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// GET /api/views/system — list system view definitions. Used by the
// editor to seed "start from a system view as a template".
func handleListSystemViews(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
writeJSON(w, http.StatusOK, services.AllSystemViews())
}
// ============================================================================
// helpers
// ============================================================================
// lookupSystemView returns a SystemView whose slug matches, or nil.
func lookupSystemView(slug string) *services.SystemView {
for _, sv := range services.AllSystemViews() {
if sv.Slug == slug {
view := sv
return &view
}
}
return nil
}
// maybeOverrideSpec replaces `spec` with body.filter when the request
// body parses as a runRequest with a non-nil Filter. Empty body / no
// override → no-op. The override is validated.
func maybeOverrideSpec(spec *services.FilterSpec, body io.Reader) error {
var p runRequest
dec := json.NewDecoder(body)
if err := dec.Decode(&p); err != nil {
// Empty body is fine — no override.
return nil
}
if p.Filter == nil {
return nil
}
if err := p.Filter.Validate(); err != nil {
return err
}
*spec = *p.Filter
return nil
}
// writeUserViewError adds slug-taken handling on top of writeServiceError.
func writeUserViewError(w http.ResponseWriter, err error) {
if err == nil {
return
}
if errors.Is(err, services.ErrUserViewSlugTaken) {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
}

View File

@@ -0,0 +1,61 @@
package handlers
// Page handlers for the Custom Views shell (t-paliad-144 Phase A2).
//
// Three URLs:
// GET /views — landing; redirects to most-recently-used
// saved view, or shows the empty/onboarding
// card.
// GET /views/{slug} — render a saved or system view.
// GET /views/new — view editor (blank slate).
// GET /views/{slug}/edit — view editor (edit existing).
//
// Each route serves the static dist HTML; the client bundle (views.ts /
// views-editor.ts) hydrates via /api/* on load.
import (
"net/http"
)
// GET /views — landing.
//
// Behaviour matches design Q10 most-recently-used:
// - If the caller has a saved view with last_used_at set → 302 to it.
// - Otherwise serve the onboarding shell (the views.html dist file
// handles the empty state in JS).
func handleViewsLandingPage(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.userView != nil {
mr, err := dbSvc.userView.MostRecent(r.Context(), uid)
if err == nil && mr != nil {
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
return
}
}
http.ServeFile(w, r, "dist/views.html")
}
// GET /views/{slug} — saved or system view shell.
//
// The handler doesn't validate the slug here — the client bundle calls
// POST /api/views/{slug}/run and lets the API surface the 404 with a
// proper empty-state. This keeps the page surface trivially cacheable.
func handleViewsShellPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/views.html")
}
// GET /views/new — editor with a blank slate.
func handleViewsNewPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/views-editor.html")
}
// GET /views/{slug}/edit — editor for an existing saved view.
func handleViewsEditPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/views-editor.html")
}

View File

@@ -147,17 +147,23 @@ type PartnerUnitMember struct {
// ProjectEvent is one row in the per-Project audit trail
// (paliad.project_events, renamed from paliad.project_events in migration 018).
//
// ProjectTitle is populated only by readers that join paliad.projects (e.g.
// ProjectService.ListEvents — Verlauf attribution for descendant events on
// /projects/{id}, t-paliad-139). Other readers leave it nil and the JSON
// serialiser omits it.
type ProjectEvent struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
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"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
}
// Deadline is one persistent deadline attached to a Project (typically a

View File

@@ -177,16 +177,33 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
return rows, nil
}
// ListForProject returns Appointments for a specific Project, visibility-checked.
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Appointment, error) {
// ListForProject returns Appointments for a Project (visibility-checked).
//
// When directOnly is false (default), the result aggregates appointments
// from the Project itself AND every descendant Project (per the
// t-paliad-139 hierarchy aggregation contract). When directOnly is true,
// only appointments whose project_id exactly equals the filter are
// returned.
//
// The descendant aggregation mirrors DeadlineService.ListForProject — see
// the doc comment there for the rationale.
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Appointment, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
rows := []models.Appointment{}
var filter string
if directOnly {
filter = `WHERE project_id = $1`
} else {
filter = `WHERE project_id IN (
SELECT p.id FROM paliad.projects p
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+appointmentColumns+`
FROM paliad.appointments
WHERE project_id = $1
`+filter+`
ORDER BY start_at ASC, created_at DESC`, projectID); err != nil {
return nil, fmt.Errorf("list appointments for project: %w", err)
}

View File

@@ -48,11 +48,14 @@ const (
)
// DecisionKind discriminates "peer" (normal in-team sign-off) from
// "admin_override" (global_admin used the escape-hatch path). Verlauf
// chronology renders these distinctly.
// "admin_override" (global_admin used the escape-hatch path) and
// "derived_peer" (a partner-unit-derived member with authority signed off
// — added by t-paliad-139 / migration 055). Verlauf chronology renders
// these distinctly.
const (
DecisionKindPeer = "peer"
DecisionKindPeer = "peer"
DecisionKindAdminOverride = "admin_override"
DecisionKindDerivedPeer = "derived_peer"
)
// levelOf maps a project_teams.role value to its strict-ladder level.

View File

@@ -84,7 +84,9 @@ func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, project
// hasQualifiedApprover counts users on the project's team-membership path
// (direct OR ancestor) whose role meets the strict-ladder threshold for
// requiredRole, plus any global_admin user. Excludes requesterID.
// requiredRole, plus any global_admin user, plus any partner-unit-derived
// member where the attachment grants authority (t-paliad-139). Excludes
// requesterID.
//
// Returns true if at least one such user exists. The path-walk JOIN matches
// the visibility predicate so an ancestor lead qualifies for a descendant's
@@ -102,6 +104,16 @@ func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx,
UNION ALL
SELECT 1 FROM paliad.users u
WHERE u.global_role = 'global_admin' AND u.id <> $2
UNION ALL
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
JOIN path ON ppu.project_id = ANY(path.ids)
WHERE pum.user_id <> $2
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
LIMIT 1
) AS ok`
var ok bool
@@ -365,8 +377,17 @@ func (s *ApprovalService) decide(ctx context.Context, requestID, callerID uuid.U
}
// canApprove enforces the strict-ladder gate plus the no-self-approval
// rule. Returns the decision_kind ('peer' | 'admin_override') the caller
// should record, or an error.
// rule. Returns the decision_kind ('peer' | 'admin_override' |
// 'derived_peer') the caller should record, or an error.
//
// Resolution order (t-paliad-139 §4.2):
// 1. Self-approval is hard-blocked.
// 2. global_admin always wins ('admin_override').
// 3. Direct or ancestor project_teams membership with sufficient role
// ('peer').
// 4. Partner-unit-derived membership with derive_grants_authority=true
// and a unit_role that maps (via approval_role_from_unit_role) to a
// project_role with sufficient level ('derived_peer').
func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID uuid.UUID, req *models.ApprovalRequest) (string, error) {
if callerID == req.RequestedBy {
return "", ErrSelfApproval
@@ -393,10 +414,31 @@ func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("authorization check: %w", err)
}
if !ok {
return "", ErrNotApprover
if ok {
return DecisionKindPeer, nil
}
return DecisionKindPeer, nil
// t-paliad-139 derivation branch: check authority-granting partner-unit
// attachments on the project's path.
qDerived := `SELECT EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
)`
var derivedOK bool
if err := tx.GetContext(ctx, &derivedOK, qDerived, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("derived authorization check: %w", err)
}
if derivedOK {
return DecisionKindDerivedPeer, nil
}
return "", ErrNotApprover
}
// applyApproved finalises the lifecycle on the entity row.
@@ -695,13 +737,29 @@ func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID u
conds := []string{
"ar.status = 'pending'",
"ar.requested_by <> $1",
// Either caller is global_admin OR caller has eligible role on the project's path.
// Eligibility (any one branch suffices):
// - caller is global_admin, OR
// - caller has direct/ancestor project_teams role meeting the threshold, OR
// - caller is a partner-unit-derived member with derive_grants_authority=true
// on an attachment in the project's path, and the unit_role maps to a
// project_role at or above the threshold (t-paliad-139).
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`,
}
args := []any{callerID}
@@ -778,6 +836,10 @@ func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (
// PendingCountForUser returns how many requests await this user's approval.
// Cheap query for the sidebar bell badge.
//
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
// ancestor project_teams role meeting the threshold OR partner-unit-
// derived authority (t-paliad-139).
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
q := `SELECT COUNT(*)
FROM paliad.approval_requests ar
@@ -790,6 +852,17 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`
var n int
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {

View File

@@ -0,0 +1,587 @@
// Package services — BroadcastService — bulk team-email send.
//
// Backs the /team page "E-Mail an Auswahl" flow (t-paliad-147 / issue #7).
// Each call:
//
// 1. Validates the sender's authority (project lead OR global_admin)
// and the recipient cap.
// 2. Renders the per-recipient body (Markdown → HTML, with
// {{name}} / {{first_name}} / {{role_on_project}} placeholder
// substitution) inside the standard email base wrapper.
// 3. Dispatches via MailService.Send with Reply-To set to the
// sender's address — From: stays on the SMTP infra address so
// DKIM/SPF still hold. Replies route back to the human.
// 4. Persists a paliad.email_broadcasts row capturing subject,
// body, sender, filter snapshot, and per-recipient send report.
//
// Per-recipient privacy: each recipient gets their own envelope. We
// never put more than one address on the To: header. Recipients can't
// see each other.
//
// Concurrency: a fixed 5-deep goroutine pool dispatches sends with a
// per-send timeout. SMTP failures are logged into the report and the
// batch continues — one bad address never blocks the rest.
package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/mail"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/models"
)
// BroadcastRecipientCap is the soft maximum number of recipients per
// broadcast. m-locked at 100 (2026-05-07) — admin-tweakable later if
// HLC's regular use case grows.
const BroadcastRecipientCap = 100
// BroadcastSendConcurrency caps the number of in-flight SMTP
// connections during a single broadcast. Five is generous enough to
// finish a 100-recipient batch in a few seconds while leaving headroom
// for the reminder job's own SMTP usage.
const BroadcastSendConcurrency = 5
// BroadcastSendTimeout bounds a single per-recipient SMTP delivery.
// Hostinger's submission endpoint typically returns within a second;
// 15s gives plenty of slack for transient slowness without holding the
// HTTP request open indefinitely.
const BroadcastSendTimeout = 15 * time.Second
// Sentinel errors. Handlers map these to HTTP status codes.
var (
ErrBroadcastForbidden = errors.New("broadcast: caller is neither project lead nor global_admin")
ErrBroadcastNoRecipients = errors.New("broadcast: empty recipient list")
ErrBroadcastTooManyRecipients = errors.New("broadcast: recipient cap exceeded")
ErrBroadcastEmptySubject = errors.New("broadcast: empty subject")
ErrBroadcastEmptyBody = errors.New("broadcast: empty body")
ErrBroadcastInvalidEmail = errors.New("broadcast: invalid recipient email")
)
// BroadcastService wires the bulk-send flow.
type BroadcastService struct {
db *sqlx.DB
mail *MailService
users *UserService
team *TeamService
templates *EmailTemplateService
// clock isolates time.Now for tests.
clock func() time.Time
}
// NewBroadcastService wires the service. mail/users/team/templates
// must all be non-nil — the service is only constructed in the DB-backed
// path.
func NewBroadcastService(db *sqlx.DB, mail *MailService, users *UserService, team *TeamService, templates *EmailTemplateService) *BroadcastService {
return &BroadcastService{
db: db,
mail: mail,
users: users,
team: team,
templates: templates,
clock: func() time.Time { return time.Now() },
}
}
// BroadcastRecipient is one row in the resolved addressee list. Name
// values are the per-recipient placeholder substitutions surfaced in
// the body.
type BroadcastRecipient struct {
UserID uuid.UUID
Email string
DisplayName string
FirstName string
RoleOnProject string
}
// BroadcastInput is what a handler hands to Send.
type BroadcastInput struct {
// ProjectID identifies the project the broadcast is scoped to. The
// caller must be a 'lead' on this project (or a global_admin) for
// the send to proceed. nil/zero means "no specific project" —
// only global_admin may send in that case.
ProjectID *uuid.UUID
Subject string
// Body is the Markdown source the sender typed. Per-recipient
// placeholders ({{name}}, {{first_name}}, {{role_on_project}})
// are substituted before Markdown rendering.
Body string
// TemplateKey is optional — when set, the broadcast is recorded as
// having started from a template, but Subject/Body are still the
// authoritative source (we don't re-fetch from the template at
// send time).
TemplateKey string
// RecipientFilter is the snapshot of filter chips the sender had
// selected. Persisted into email_broadcasts.recipient_filter for
// future audit.
RecipientFilter map[string]any
Recipients []BroadcastRecipient
// Lang controls the wrapper template language. Defaults to "de".
Lang string
}
// BroadcastReport summarises a send.
type BroadcastReport struct {
BroadcastID uuid.UUID `json:"broadcast_id"`
Total int `json:"total"`
Sent int `json:"sent"`
Failed int `json:"failed"`
Errors map[string]string `json:"errors,omitempty"` // user_id → error
SentAt time.Time `json:"sent_at"`
}
// Send dispatches a broadcast. Returns the persisted ID and a per-send
// report. The full pipeline runs even when MailService is disabled —
// the audit row still lands so deploys without SMTP can be exercised.
func (s *BroadcastService) Send(ctx context.Context, callerID uuid.UUID, in BroadcastInput) (*BroadcastReport, error) {
// --- Validation (cheap checks first) ----------------------------
subject := strings.TrimSpace(in.Subject)
if subject == "" {
return nil, ErrBroadcastEmptySubject
}
body := strings.TrimSpace(in.Body)
if body == "" {
return nil, ErrBroadcastEmptyBody
}
if len(in.Recipients) == 0 {
return nil, ErrBroadcastNoRecipients
}
if len(in.Recipients) > BroadcastRecipientCap {
return nil, fmt.Errorf("%w: %d > %d", ErrBroadcastTooManyRecipients, len(in.Recipients), BroadcastRecipientCap)
}
for _, r := range in.Recipients {
if _, err := mail.ParseAddress(r.Email); err != nil {
return nil, fmt.Errorf("%w: %q", ErrBroadcastInvalidEmail, r.Email)
}
}
// --- Authorisation ---------------------------------------------
sender, err := s.users.GetByID(ctx, callerID)
if err != nil {
return nil, fmt.Errorf("load sender: %w", err)
}
if sender == nil {
return nil, ErrBroadcastForbidden
}
if err := s.assertCanBroadcast(ctx, sender, in.ProjectID); err != nil {
return nil, err
}
// --- Persist audit row ahead of send so a partial-batch crash
// still leaves a record of intent. send_report is filled in
// post-dispatch via UPDATE.
lang := in.Lang
if lang == "" {
lang = "de"
}
broadcastID := uuid.New()
recipientIDs := make([]uuid.UUID, 0, len(in.Recipients))
for _, r := range in.Recipients {
recipientIDs = append(recipientIDs, r.UserID)
}
filterJSON, err := json.Marshal(filterMapOrEmpty(in.RecipientFilter))
if err != nil {
return nil, fmt.Errorf("marshal filter: %w", err)
}
templateKey := strings.TrimSpace(in.TemplateKey)
var templateKeyArg any
if templateKey != "" {
templateKeyArg = templateKey
}
if _, err := s.db.ExecContext(ctx, `
INSERT INTO paliad.email_broadcasts
(id, subject, body, sender_id, template_key, recipient_filter, recipient_user_ids, send_report, sent_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, '{}'::jsonb, now())`,
broadcastID, subject, body, callerID, templateKeyArg, string(filterJSON), pq.Array(recipientIDs),
); err != nil {
return nil, fmt.Errorf("insert broadcast: %w", err)
}
// --- Dispatch -------------------------------------------------
report, sendErr := s.dispatch(ctx, *sender, broadcastID, subject, body, lang, in.Recipients)
report.BroadcastID = broadcastID
// Persist the report regardless of dispatch outcome; surface the
// dispatch error to the caller so the UI can show a partial-success
// toast.
reportJSON, marshalErr := json.Marshal(report)
if marshalErr != nil {
// Truly unexpected — fall back to an empty report shape rather
// than wedging the audit row.
slog.Error("broadcast: marshal report failed", "broadcast_id", broadcastID, "error", marshalErr)
reportJSON = []byte(`{}`)
}
if _, err := s.db.ExecContext(ctx,
`UPDATE paliad.email_broadcasts SET send_report = $1::jsonb WHERE id = $2`,
string(reportJSON), broadcastID,
); err != nil {
slog.Error("broadcast: persist report failed", "broadcast_id", broadcastID, "error", err)
}
if sendErr != nil {
return report, sendErr
}
return report, nil
}
// assertCanBroadcast enforces project_lead-OR-global_admin. global_admin
// always wins; otherwise the sender must have role='lead' on
// in.ProjectID.
func (s *BroadcastService) assertCanBroadcast(ctx context.Context, sender *models.User, projectID *uuid.UUID) error {
if sender.GlobalRole == "global_admin" {
return nil
}
if projectID == nil {
return ErrBroadcastForbidden
}
var count int
if err := s.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2 AND role = 'lead'`,
*projectID, sender.ID,
); err != nil {
return fmt.Errorf("check lead role: %w", err)
}
if count == 0 {
return ErrBroadcastForbidden
}
return nil
}
// dispatch fans out the per-recipient sends through a bounded pool and
// collects the report.
func (s *BroadcastService) dispatch(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, recipients []BroadcastRecipient) (*BroadcastReport, error) {
type result struct {
userID uuid.UUID
err error
}
results := make(chan result, len(recipients))
sem := make(chan struct{}, BroadcastSendConcurrency)
var wg sync.WaitGroup
for _, r := range recipients {
wg.Add(1)
go func(rec BroadcastRecipient) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
sendCtx, cancel := context.WithTimeout(ctx, BroadcastSendTimeout)
defer cancel()
err := s.sendOne(sendCtx, sender, broadcastID, subject, body, lang, rec)
results <- result{userID: rec.UserID, err: err}
}(r)
}
wg.Wait()
close(results)
report := &BroadcastReport{
Total: len(recipients),
Errors: map[string]string{},
SentAt: s.clock(),
}
for res := range results {
if res.err != nil {
report.Failed++
report.Errors[res.userID.String()] = res.err.Error()
slog.Warn("broadcast: send failed",
"broadcast_id", broadcastID, "user_id", res.userID, "error", res.err)
} else {
report.Sent++
}
}
return report, nil
}
// sendOne renders one personalised email and dispatches it. The
// MailService no-ops cleanly when disabled — that path still treats
// the recipient as "sent" for the purposes of the report so dev
// deploys aren't littered with phantom failures.
func (s *BroadcastService) sendOne(ctx context.Context, sender models.User, broadcastID uuid.UUID, subject, body, lang string, rec BroadcastRecipient) error {
// Subject can carry placeholders too ("Hallo {{first_name}}, …").
rendered := substitutePlaceholders(subject, rec)
personalisedBody := substitutePlaceholders(body, rec)
htmlBody, err := s.renderBroadcastBody(ctx, lang, personalisedBody, sender)
if err != nil {
return fmt.Errorf("render body: %w", err)
}
textBody := htmlToText(htmlBody)
// Custom envelope — we want Reply-To: sender so replies route to the
// human who composed the broadcast.
if !s.mail.Enabled() {
slog.Debug("broadcast: SendOne skipped (mail disabled)",
"broadcast_id", broadcastID, "to", rec.Email)
return nil
}
msg := buildMIMEWithReplyTo(s.mail.cfg.From, s.mail.cfg.FromName, sender.Email,
rec.Email, rendered, htmlBody, textBody)
deliverDone := make(chan error, 1)
go func() {
deliverDone <- s.mail.deliver(rec.Email, msg)
}()
select {
case err := <-deliverDone:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// renderBroadcastBody wraps the personalised Markdown body in the
// standard base.html (DB override or embedded fallback) so broadcast
// emails look like the rest of Paliad's mail.
func (s *BroadcastService) renderBroadcastBody(ctx context.Context, lang, markdownBody string, sender models.User) (string, error) {
htmlContent := renderMarkdownSafe(markdownBody)
signature := senderSignature(lang, sender)
// Build the {{define "content"}} block expected by base.html. The
// inner HTML is treated as trusted output (we generated it from
// known-safe Markdown rules). Senders can't sneak script tags
// because renderMarkdownSafe escapes everything before re-introducing
// the whitelisted markup.
contentBlock := fmt.Sprintf(`{{define "content"}}%s%s{{end}}`, htmlContent, signature)
// Look up base.html (key='base'). Same fallback discipline as
// MailService.RenderTemplate — if the active row is malformed we
// retry with the embedded default.
var (
baseBody string
err error
)
if s.templates != nil {
row, lookupErr := s.templates.GetActive(ctx, EmailTemplateKeyBase, lang)
if lookupErr != nil {
return "", fmt.Errorf("lookup base template: %w", lookupErr)
}
baseBody = row.Body
} else {
baseBody, err = readEmbeddedBody(EmailTemplateKeyBase, lang)
if err != nil {
return "", fmt.Errorf("read embedded base: %w", err)
}
}
payload := map[string]any{
"Lang": lang,
"Firm": branding.Name,
"Subject": "", // base.html title field; we don't need it here.
}
html, err := renderBaseAndContent(baseBody, contentBlock, payload)
if err == nil {
return html, nil
}
// Active row malformed — fall back to embedded.
slog.Error("broadcast: base render failed, falling back to embedded",
"lang", lang, "error", err)
fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
if fbErr != nil {
return "", fmt.Errorf("fallback base: %w", fbErr)
}
return renderBaseAndContent(fbBase, contentBlock, payload)
}
// substitutePlaceholders replaces {{name}}, {{first_name}}, and
// {{role_on_project}} with the per-recipient values. Whitespace
// inside the braces is tolerated. Unknown {{...}} tokens pass through
// untouched so a sender's accidental "literal {{example}}" stays
// readable in the rendered mail.
func substitutePlaceholders(src string, rec BroadcastRecipient) string {
repl := strings.NewReplacer(
"{{name}}", rec.DisplayName,
"{{ name }}", rec.DisplayName,
"{{first_name}}", rec.FirstName,
"{{ first_name }}", rec.FirstName,
"{{role_on_project}}", rec.RoleOnProject,
"{{ role_on_project }}", rec.RoleOnProject,
)
return repl.Replace(src)
}
// senderSignature appends a "Geschickt von <DisplayName> <email>"
// footer below the body so the recipient sees who wrote the mail
// even though From: is the SMTP infrastructure address.
func senderSignature(lang string, sender models.User) string {
prefix := "Gesendet von"
if lang == "en" {
prefix = "Sent by"
}
if sender.DisplayName == "" {
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s <a href="mailto:%s">%s</a></p>`,
prefix, escapeHTML(sender.Email), escapeHTML(sender.Email))
}
return fmt.Sprintf(`<p style="margin-top:24px;font-size:13px;color:#78716c;">%s %s &lt;<a href="mailto:%s">%s</a>&gt;</p>`,
prefix, escapeHTML(sender.DisplayName), escapeHTML(sender.Email), escapeHTML(sender.Email))
}
// filterMapOrEmpty normalises a nil filter map to an empty one for
// jsonb persistence.
func filterMapOrEmpty(in map[string]any) map[string]any {
if in == nil {
return map[string]any{}
}
return in
}
// --- broadcast list / get queries ----------------------------------
// BroadcastListEntry is one row on the /admin/broadcasts list.
type BroadcastListEntry struct {
ID uuid.UUID `db:"id" json:"id"`
Subject string `db:"subject" json:"subject"`
SenderID uuid.UUID `db:"sender_id" json:"sender_id"`
SenderName string `db:"sender_name" json:"sender_name"`
SenderEmail string `db:"sender_email" json:"sender_email"`
RecipientCount int `db:"recipient_count" json:"recipient_count"`
SentAt time.Time `db:"sent_at" json:"sent_at"`
TemplateKey *string `db:"template_key" json:"template_key,omitempty"`
}
// BroadcastDetail is the per-row detail view.
type BroadcastDetail struct {
BroadcastListEntry
Body string `db:"body" json:"body"`
RecipientFilter json.RawMessage `db:"recipient_filter" json:"recipient_filter"`
SendReport json.RawMessage `db:"send_report" json:"send_report"`
Recipients []BroadcastDetailRecipient `json:"recipients"`
}
// BroadcastDetailRecipient is one resolved addressee on the detail page.
// Names are joined from paliad.users at read time so the most recent
// display_name shows up; the audit row only retains the user_id.
type BroadcastDetailRecipient struct {
UserID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
DisplayName string `db:"display_name" json:"display_name"`
}
// List returns broadcasts visible to the caller. global_admin sees
// every row; everyone else sees only their own sends.
func (s *BroadcastService) List(ctx context.Context, callerID uuid.UUID, limit int) ([]BroadcastListEntry, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
caller, err := s.users.GetByID(ctx, callerID)
if err != nil {
return nil, fmt.Errorf("load caller: %w", err)
}
if caller == nil {
return nil, ErrBroadcastForbidden
}
var (
rows []BroadcastListEntry
q string
args []any
)
if caller.GlobalRole == "global_admin" {
q = listBroadcastsSQL + ` ORDER BY b.sent_at DESC LIMIT $1`
args = []any{limit}
} else {
q = listBroadcastsSQL + ` WHERE b.sender_id = $1 ORDER BY b.sent_at DESC LIMIT $2`
args = []any{callerID, limit}
}
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, fmt.Errorf("list broadcasts: %w", err)
}
return rows, nil
}
// Get returns one broadcast plus its resolved recipient list. Same
// visibility rules as List.
func (s *BroadcastService) Get(ctx context.Context, callerID, id uuid.UUID) (*BroadcastDetail, error) {
caller, err := s.users.GetByID(ctx, callerID)
if err != nil {
return nil, fmt.Errorf("load caller: %w", err)
}
if caller == nil {
return nil, ErrBroadcastForbidden
}
var detail BroadcastDetail
q := `
SELECT b.id, b.subject, b.sender_id, b.template_key,
array_length(b.recipient_user_ids, 1) AS recipient_count,
b.sent_at, b.body, b.recipient_filter, b.send_report,
u.display_name AS sender_name, u.email AS sender_email
FROM paliad.email_broadcasts b
LEFT JOIN paliad.users u ON u.id = b.sender_id
WHERE b.id = $1`
if err := s.db.GetContext(ctx, &detail, q, id); err != nil {
return nil, fmt.Errorf("get broadcast: %w", err)
}
if caller.GlobalRole != "global_admin" && detail.SenderID != callerID {
return nil, ErrBroadcastForbidden
}
// Resolve recipient names. The audit row stores user_ids only; we
// re-join paliad.users at read time so renames flow through. The
// uuid[] column comes back as pq.Array; copy it out for sqlx.
var idArr pq.StringArray
if err := s.db.GetContext(ctx, &idArr,
`SELECT recipient_user_ids::text[] FROM paliad.email_broadcasts WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("load recipient ids: %w", err)
}
recipientIDs := make([]uuid.UUID, 0, len(idArr))
for _, s := range idArr {
if uid, err := uuid.Parse(s); err == nil {
recipientIDs = append(recipientIDs, uid)
}
}
if len(recipientIDs) > 0 {
var rec []BroadcastDetailRecipient
if err := s.db.SelectContext(ctx, &rec,
`SELECT id, email, display_name
FROM paliad.users
WHERE id = ANY($1)`, pq.Array(recipientIDs),
); err != nil {
return nil, fmt.Errorf("load recipients: %w", err)
}
// Preserve the audit-row order — clients want the original
// dispatch list, not whatever paliad.users ordered them by.
byID := make(map[uuid.UUID]BroadcastDetailRecipient, len(rec))
for _, r := range rec {
byID[r.UserID] = r
}
ordered := make([]BroadcastDetailRecipient, 0, len(recipientIDs))
for _, uid := range recipientIDs {
if r, ok := byID[uid]; ok {
ordered = append(ordered, r)
continue
}
// User row was deleted post-broadcast. Show the bare ID so
// the audit page still accounts for the slot.
ordered = append(ordered, BroadcastDetailRecipient{UserID: uid})
}
detail.Recipients = ordered
}
return &detail, nil
}
const listBroadcastsSQL = `
SELECT b.id, b.subject, b.sender_id, b.template_key,
COALESCE(array_length(b.recipient_user_ids, 1), 0) AS recipient_count,
b.sent_at,
u.display_name AS sender_name, u.email AS sender_email
FROM paliad.email_broadcasts b
LEFT JOIN paliad.users u ON u.id = b.sender_id
`

View File

@@ -0,0 +1,191 @@
package services
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestBroadcastService_SendAndAudit_Live exercises the full BroadcastService
// pipeline against a real Postgres: the row lands in paliad.email_broadcasts,
// the send_report jsonb captures per-recipient outcomes, and List/Get
// honours the visibility rules (sender sees own; global_admin sees all).
//
// SMTP delivery is not exercised — the MailService is left disabled
// (Enabled() == false) so sendOne short-circuits cleanly. That's the same
// contract the dev/preview deploys run under.
//
// Skipped when TEST_DATABASE_URL is unset.
func TestBroadcastService_SendAndAudit_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
leadID := uuid.New()
memberID := uuid.New()
otherSenderID := uuid.New()
projectID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Bcast Lead', 'munich', 'standard', 'de'),
($3, $4, 'Bcast Mem', 'munich', 'standard', 'de'),
($5, $6, 'Bcast Admin', 'munich', 'global_admin', 'de')`,
leadID, "bcast-lead@hlc.com",
memberID, "bcast-member@hlc.com",
otherSenderID, "bcast-admin@hlc.com",
); err != nil {
t.Fatalf("seed users: %v", err)
}
t.Cleanup(func() {
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.email_broadcasts WHERE sender_id = ANY($1)`,
[]string{leadID.String(), otherSenderID.String()})
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = ANY($1)`,
[]string{leadID.String(), memberID.String(), otherSenderID.String()})
})
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, status, created_by)
VALUES ($1, 'project', $1::text, 'Bcast Project', 'active', $2)`,
projectID, leadID,
); err != nil {
t.Fatalf("seed project: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
VALUES ($1, $2, 'lead', false, $2),
($1, $3, 'associate', false, $2)`,
projectID, leadID, memberID,
); err != nil {
t.Fatalf("seed team: %v", err)
}
users := NewUserService(pool)
projectSvc := NewProjectService(pool, users)
teamSvc := NewTeamService(pool, projectSvc)
mailSvc, err := NewMailService()
if err != nil {
t.Fatalf("mail svc: %v", err)
}
tplSvc := NewEmailTemplateService(pool)
mailSvc.SetTemplateService(tplSvc)
bcast := NewBroadcastService(pool, mailSvc, users, teamSvc, tplSvc)
// --- 1. lead can send a broadcast on their project --------------
pid := projectID
report, err := bcast.Send(ctx, leadID, BroadcastInput{
ProjectID: &pid,
Subject: "Hallo Team",
Body: "Hi {{first_name}}, kurze Nachricht.",
Recipients: []BroadcastRecipient{{
UserID: memberID,
Email: "bcast-member@hlc.com",
DisplayName: "Bcast Mem",
FirstName: "Bcast",
RoleOnProject: "associate",
}},
RecipientFilter: map[string]any{"project_ids": []string{pid.String()}},
})
if err != nil {
t.Fatalf("Send (lead): %v", err)
}
if report.BroadcastID == uuid.Nil {
t.Fatal("BroadcastID empty")
}
if report.Total != 1 {
t.Errorf("Total=%d, want 1", report.Total)
}
if report.Sent != 1 || report.Failed != 0 {
t.Errorf("Sent=%d Failed=%d, want Sent=1 Failed=0", report.Sent, report.Failed)
}
// --- 2. non-lead sender (member) → forbidden --------------------
_, err = bcast.Send(ctx, memberID, BroadcastInput{
ProjectID: &pid,
Subject: "Should fail",
Body: "x",
Recipients: []BroadcastRecipient{{
UserID: leadID, Email: "bcast-lead@hlc.com", DisplayName: "Bcast Lead",
}},
})
if err == nil || !errorIs(err, ErrBroadcastForbidden) {
t.Errorf("non-lead Send: got %v, want ErrBroadcastForbidden", err)
}
// --- 3. global_admin sees all rows in List ----------------------
rowsAdmin, err := bcast.List(ctx, otherSenderID, 50)
if err != nil {
t.Fatalf("List(admin): %v", err)
}
foundOurRow := false
for _, r := range rowsAdmin {
if r.ID == report.BroadcastID {
foundOurRow = true
if r.RecipientCount != 1 {
t.Errorf("RecipientCount=%d, want 1", r.RecipientCount)
}
}
}
if !foundOurRow {
t.Error("admin's List did not include our broadcast")
}
// --- 4. lead sees own rows --------------------------------------
rowsLead, err := bcast.List(ctx, leadID, 50)
if err != nil {
t.Fatalf("List(lead): %v", err)
}
if len(rowsLead) == 0 || rowsLead[0].ID != report.BroadcastID {
t.Errorf("lead List didn't return own row; got %+v", rowsLead)
}
// --- 5. non-sender, non-admin gets nothing back -----------------
rowsMember, err := bcast.List(ctx, memberID, 50)
if err != nil {
t.Fatalf("List(member): %v", err)
}
for _, r := range rowsMember {
if r.ID == report.BroadcastID {
t.Errorf("member should not see lead's broadcast %s", r.ID)
}
}
// --- 6. Get returns full detail w/ recipients -------------------
detail, err := bcast.Get(ctx, leadID, report.BroadcastID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if detail.Subject != "Hallo Team" {
t.Errorf("Subject=%q", detail.Subject)
}
if len(detail.Recipients) != 1 {
t.Errorf("Recipients=%d, want 1", len(detail.Recipients))
}
if len(detail.Recipients) >= 1 && detail.Recipients[0].UserID != memberID {
t.Errorf("Recipients[0].UserID=%s, want %s", detail.Recipients[0].UserID, memberID)
}
// --- 7. member calling Get on lead's row → forbidden -----------
if _, err := bcast.Get(ctx, memberID, report.BroadcastID); err == nil ||
!errorIs(err, ErrBroadcastForbidden) {
t.Errorf("member Get: got %v, want ErrBroadcastForbidden", err)
}
}

View File

@@ -0,0 +1,233 @@
package services
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestSubstitutePlaceholders(t *testing.T) {
rec := BroadcastRecipient{
UserID: uuid.New(),
Email: "anna@hlc.com",
DisplayName: "Anna Beispiel",
FirstName: "Anna",
RoleOnProject: "lead",
}
cases := []struct {
name string
in string
want string
}{
{"name", "Hallo {{name}}", "Hallo Anna Beispiel"},
{"first_name", "Hi {{first_name}}!", "Hi Anna!"},
{"role_on_project", "Du bist {{role_on_project}}.", "Du bist lead."},
{"whitespace tolerated", "{{ first_name }}", "Anna"},
{"unknown token passes through", "Literal {{example}} stays", "Literal {{example}} stays"},
{"all three together",
"{{name}} ({{first_name}}, {{role_on_project}})",
"Anna Beispiel (Anna, lead)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := substitutePlaceholders(tc.in, rec)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// renderMarkdownSafe must escape raw HTML and only re-emit a small whitelist
// of tags. Any leakage of a <script> tag would be an XSS vector since the
// rendered output goes straight into an HTML email body.
func TestRenderMarkdownSafe(t *testing.T) {
cases := []struct {
name string
in string
wantContains []string
wantMissing []string
}{
{
name: "bold",
in: "**hallo**",
wantContains: []string{"<strong>hallo</strong>"},
},
{
name: "italic underscore",
in: "_hallo_",
wantContains: []string{"<em>hallo</em>"},
},
{
name: "link",
in: "[paliad](https://paliad.de)",
wantContains: []string{`<a href="https://paliad.de">paliad</a>`},
},
{
name: "bullet list",
in: "- erstens\n- zweitens",
wantContains: []string{"<ul>", "<li>erstens</li>", "<li>zweitens</li>", "</ul>"},
},
{
name: "paragraph break",
in: "Erste Zeile\n\nZweite Zeile",
wantContains: []string{"<p>Erste Zeile</p>", "<p>Zweite Zeile</p>"},
},
{
name: "single newline → br",
in: "Zeile A\nZeile B",
wantContains: []string{"<p>Zeile A<br>", "Zeile B</p>"},
},
{
name: "script tag escaped",
in: "Hallo <script>alert(1)</script>",
wantContains: []string{"&lt;script&gt;", "&lt;/script&gt;"},
wantMissing: []string{"<script>", "alert(1)</script>"},
},
{
name: "link injection attempt — javascript: URL is rejected",
in: "[click](javascript:alert(1))",
wantMissing: []string{`href="javascript:`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := renderMarkdownSafe(tc.in)
for _, want := range tc.wantContains {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %q", want, got)
}
}
for _, miss := range tc.wantMissing {
if strings.Contains(got, miss) {
t.Errorf("unexpected %q in %q", miss, got)
}
}
})
}
}
func TestFirstNameExtraction(t *testing.T) {
// senderSignature uses DisplayName directly; firstName extraction is
// frontend-side. Smoke-test only that DisplayName placeholder lands.
sender := models.User{
ID: uuid.New(),
Email: "max@hlc.com",
DisplayName: "Max Mustermann",
}
sig := senderSignature("de", sender)
if !strings.Contains(sig, "Max Mustermann") {
t.Errorf("DisplayName not in signature: %q", sig)
}
if !strings.Contains(sig, "Gesendet von") {
t.Errorf("DE prefix missing: %q", sig)
}
if !strings.Contains(sig, `mailto:max@hlc.com`) {
t.Errorf("mailto link missing: %q", sig)
}
sigEN := senderSignature("en", sender)
if !strings.Contains(sigEN, "Sent by") {
t.Errorf("EN prefix missing: %q", sigEN)
}
}
// TestBroadcastValidation exercises the cheap guards that fire before any
// SQL or SMTP I/O. Constructed with a nil DB so the tests don't need a
// connection string. The Send path bails out at validation before touching
// db.ExecContext.
func TestBroadcastValidation(t *testing.T) {
mailSvc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
svc := NewBroadcastService(nil, mailSvc, nil, nil, NewEmailTemplateService(nil))
cases := []struct {
name string
in BroadcastInput
want error
}{
{
name: "empty subject",
in: BroadcastInput{Subject: "", Body: "x", Recipients: oneRec()},
want: ErrBroadcastEmptySubject,
},
{
name: "empty body",
in: BroadcastInput{Subject: "Hi", Body: " ", Recipients: oneRec()},
want: ErrBroadcastEmptyBody,
},
{
name: "no recipients",
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nil},
want: ErrBroadcastNoRecipients,
},
{
name: "too many recipients",
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nRecipients(BroadcastRecipientCap + 1)},
want: ErrBroadcastTooManyRecipients,
},
{
name: "invalid email",
in: BroadcastInput{
Subject: "Hi",
Body: "x",
Recipients: []BroadcastRecipient{{
UserID: uuid.New(),
Email: "not-an-email",
}},
},
want: ErrBroadcastInvalidEmail,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.Send(t.Context(), uuid.New(), tc.in)
if err == nil {
t.Fatal("expected error, got nil")
}
// Use errors.Is so wrapped errors still match.
if !errorIs(err, tc.want) {
t.Errorf("got %v, want %v", err, tc.want)
}
})
}
}
// errorIs is a tiny shim so the test file doesn't need to import "errors".
// (Imports are kept terse on purpose — see existing test files.)
func errorIs(have, want error) bool {
if have == want {
return true
}
if have == nil || want == nil {
return false
}
// Fall back to message-level matching for fmt.Errorf %w wraps.
return strings.Contains(have.Error(), want.Error())
}
func oneRec() []BroadcastRecipient {
return []BroadcastRecipient{{
UserID: uuid.New(),
Email: "anna@hlc.com",
DisplayName: "Anna",
FirstName: "Anna",
}}
}
func nRecipients(n int) []BroadcastRecipient {
out := make([]BroadcastRecipient, 0, n)
for i := 0; i < n; i++ {
out = append(out, BroadcastRecipient{
UserID: uuid.New(),
Email: "user@hlc.com",
DisplayName: "User",
FirstName: "User",
})
}
return out
}

View File

@@ -241,16 +241,35 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
return rows, nil
}
// ListForProject returns Deadlines for a specific Project (visibility-checked).
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Deadline, error) {
// ListForProject returns Deadlines for a Project (visibility-checked).
//
// When directOnly is false (default), the result aggregates deadlines from
// the Project itself AND every descendant Project (per the t-paliad-139
// hierarchy aggregation contract). When directOnly is true, only deadlines
// whose project_id exactly equals the filter are returned — useful for
// edit / attribution surfaces that want exact narrowing.
//
// The descendant aggregation reuses the materialised path on
// paliad.projects (text-shaped, t-paliad-018). The visibility check on
// the filter Project is sufficient: paliad.can_see_project walks ancestors,
// so a user who can see Project P can see every descendant of P.
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
rows := []models.Deadline{}
var filter string
if directOnly {
filter = `WHERE project_id = $1`
} else {
filter = `WHERE project_id IN (
SELECT p.id FROM paliad.projects p
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+deadlineColumns+`
FROM paliad.deadlines
WHERE project_id = $1
`+filter+`
ORDER BY due_date ASC, created_at DESC`, projectID); err != nil {
return nil, fmt.Errorf("list deadlines for project: %w", err)
}

View File

@@ -0,0 +1,71 @@
package services
import (
"testing"
"github.com/google/uuid"
)
// TestDerivedMembershipListScan covers the sql.Scanner over a Postgres
// jsonb column — the wire format that ListDerivedMembers' jsonb_agg
// returns. Pinned because if a future migration changes the JSON shape
// (e.g. drops a key), the rendered Herkunft column on /projects/{id}
// silently breaks (t-paliad-143).
func TestDerivedMembershipListScan(t *testing.T) {
unitA := uuid.New()
unitB := uuid.New()
cases := []struct {
name string
src any
want []DerivedMembership
}{
{
name: "nil",
src: nil,
want: nil,
},
{
name: "single membership as bytes",
src: []byte(`[{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"}]`),
want: []DerivedMembership{{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"}},
},
{
name: "two memberships as string",
src: `[
{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"},
{"unit_id":"` + unitB.String() + `","unit_name":"Plassmann","unit_role":"pa"}
]`,
want: []DerivedMembership{
{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"},
{UnitID: unitB, UnitName: "Plassmann", UnitRole: "pa"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got DerivedMembershipList
if err := got.Scan(tc.src); err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len: got %d want %d", len(got), len(tc.want))
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("row %d: got %+v want %+v", i, got[i], tc.want[i])
}
}
})
}
}
// TestDerivedMembershipListScanRejectsUnknown ensures we don't silently
// accept random column types and produce an empty list (which would mask
// a schema regression).
func TestDerivedMembershipListScanRejectsUnknown(t *testing.T) {
var l DerivedMembershipList
if err := l.Scan(123); err == nil {
t.Fatal("expected error scanning int into DerivedMembershipList, got nil")
}
}

View File

@@ -0,0 +1,452 @@
package services
// DerivationService manages partner-unit derivation onto project teams
// (t-paliad-139). It owns the project↔unit junction table
// (paliad.project_partner_units) and the read paths the Team tab + the
// approval inbox use to compute "who's effectively on this project via a
// partner unit".
//
// Derivation is computed on read (no materialised state). The visibility
// predicate paliad.can_see_project (extended in migration 055) is the
// authoritative gate for what users can see; this service is the read /
// authoring API on top of it.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// DerivationService is the read + authoring path for partner-unit derivation.
type DerivationService struct {
db *sqlx.DB
projects *ProjectService
partnerUnit *PartnerUnitService
}
// NewDerivationService wires the service.
func NewDerivationService(db *sqlx.DB, projects *ProjectService, partnerUnit *PartnerUnitService) *DerivationService {
return &DerivationService{db: db, projects: projects, partnerUnit: partnerUnit}
}
// AttachedUnit is one row in paliad.project_partner_units enriched with the
// unit's display name + count of members that would currently derive given
// the configured derive_unit_roles. The frontend renders this on the
// /projects/{id}/settings/team Partner Units section.
type AttachedUnit struct {
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
UnitName string `db:"unit_name" json:"unit_name"`
DeriveUnitRoles []string `db:"derive_unit_roles" json:"derive_unit_roles"`
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
}
// DerivedMembership is one (unit, role) pair through which a user currently
// derives onto a project. A multi-unit user has one DerivedMembership per
// unit they belong to that's attached to the project (or one of its
// ancestors) AND whose unit_role is in the attachment's derive_unit_roles.
type DerivedMembership struct {
UnitID uuid.UUID `json:"unit_id"`
UnitName string `json:"unit_name"`
UnitRole string `json:"unit_role"`
}
// DerivedMembershipList is a []DerivedMembership that scans from a Postgres
// jsonb column (the array_agg/jsonb_agg payload in ListDerivedMembers).
type DerivedMembershipList []DerivedMembership
// Scan implements sql.Scanner over a jsonb array.
func (l *DerivedMembershipList) Scan(src any) error {
if src == nil {
*l = nil
return nil
}
var raw []byte
switch v := src.(type) {
case []byte:
raw = v
case string:
raw = []byte(v)
default:
return fmt.Errorf("DerivedMembershipList.Scan: unsupported type %T", src)
}
return json.Unmarshal(raw, (*[]DerivedMembership)(l))
}
// DerivedMember is one user who currently derives onto a project. The user
// may derive via multiple units (e.g. a PA who works with two partners);
// each is one entry in Memberships. DeriveGrantsAuthority is true if any
// of the source attachments have authority enabled.
type DerivedMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Email string `db:"email" json:"user_email"`
DisplayName string `db:"display_name" json:"user_display_name"`
Office string `db:"office" json:"user_office"`
Memberships DerivedMembershipList `db:"memberships" json:"memberships"`
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
}
// AttachUnitOptions controls how a unit is attached. Empty values use the
// migration-055 defaults: derive_unit_roles = {pa, senior_pa},
// derive_grants_authority = false (visibility-only).
type AttachUnitOptions struct {
DeriveUnitRoles []string
DeriveGrantsAuthority bool
}
// requireWritePermission gates project↔unit attach/detach to project lead
// or global_admin. Mirrors the RLS write policy in migration 055.
func (s *DerivationService) requireWritePermission(ctx context.Context, callerID, projectID uuid.UUID) error {
user, err := s.projects.Users().GetByID(ctx, callerID)
if err != nil {
return err
}
if user != nil && user.GlobalRole == "global_admin" {
return nil
}
var role string
err = s.db.GetContext(ctx, &role,
`SELECT role FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2`,
projectID, callerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrForbidden
}
if err != nil {
return fmt.Errorf("read project_teams role: %w", err)
}
if role != RoleLead {
return ErrForbidden
}
return nil
}
// AttachUnitToProject creates a project_partner_units row. Idempotent on
// (project_id, partner_unit_id) — a repeat call updates the derive options.
// Caller must be project lead OR global_admin.
func (s *DerivationService) AttachUnitToProject(ctx context.Context, callerID, projectID, unitID uuid.UUID, opts AttachUnitOptions) error {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return err
}
if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
return err
}
if _, err := s.partnerUnit.GetByID(ctx, unitID); err != nil {
return err
}
roles := opts.DeriveUnitRoles
if len(roles) == 0 {
roles = []string{UnitRolePA, UnitRoleSeniorPA}
}
for _, r := range roles {
if !isValidUnitRole(r) {
return fmt.Errorf("%w: invalid unit_role %q in derive_unit_roles", ErrInvalidInput, r)
}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.project_partner_units
(project_id, partner_unit_id, derive_unit_roles, derive_grants_authority,
attached_at, attached_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (project_id, partner_unit_id) DO UPDATE
SET derive_unit_roles = EXCLUDED.derive_unit_roles,
derive_grants_authority = EXCLUDED.derive_grants_authority`,
projectID, unitID, pq.StringArray(roles), opts.DeriveGrantsAuthority, callerID)
if err != nil {
return fmt.Errorf("attach unit to project: %w", err)
}
return nil
}
// DetachUnitFromProject deletes a project_partner_units row. Idempotent —
// repeat detach is a no-op.
func (s *DerivationService) DetachUnitFromProject(ctx context.Context, callerID, projectID, unitID uuid.UUID) error {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return err
}
if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.project_partner_units
WHERE project_id = $1 AND partner_unit_id = $2`,
projectID, unitID); err != nil {
return fmt.Errorf("detach unit from project: %w", err)
}
return nil
}
// ListAttachedUnits returns the unit attachments anchored on this exact
// project (NOT walking ancestors — the project /settings/team page wants
// to manage its own attachments only). Each row is enriched with the unit
// name and the count of members that would currently derive given the
// configured derive_unit_roles.
func (s *DerivationService) ListAttachedUnits(ctx context.Context, callerID, projectID uuid.UUID) ([]AttachedUnit, error) {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
rows := []AttachedUnit{}
err := s.db.SelectContext(ctx, &rows,
`SELECT ppu.project_id,
ppu.partner_unit_id,
pu.name AS unit_name,
ppu.derive_unit_roles,
ppu.derive_grants_authority,
(SELECT COUNT(*) FROM paliad.partner_unit_members pum
WHERE pum.partner_unit_id = ppu.partner_unit_id
AND pum.unit_role = ANY(ppu.derive_unit_roles)) AS derived_member_count
FROM paliad.project_partner_units ppu
JOIN paliad.partner_units pu ON pu.id = ppu.partner_unit_id
WHERE ppu.project_id = $1
ORDER BY pu.name`, projectID)
if err != nil {
return nil, fmt.Errorf("list attached units: %w", err)
}
return rows, nil
}
// ListDerivedMembers returns users who currently derive onto this project
// via any attached unit on the project's path (this project + ancestors).
// Walks UP the path because a unit attached at the Client level cascades
// down to descendants — derivation honours the same direction as
// can_see_project.
//
// One row per user. Multi-unit users (e.g. a PA working across two partner
// units, both of which are attached to the project's path) carry every
// (unit, role) pair in Memberships so the Herkunft column can list them
// all (t-paliad-143). DeriveGrantsAuthority is bool_or across the
// underlying attachments — a user with at least one authority-granting
// derivation source qualifies as authority-bearing for approval purposes.
func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) {
project, err := s.projects.GetByID(ctx, callerID, projectID)
if err != nil {
return nil, err
}
ancestorIDs := pathToIDStrings(project.Path)
if len(ancestorIDs) == 0 {
return []DerivedMember{}, nil
}
rows := []DerivedMember{}
err = s.db.SelectContext(ctx, &rows, `
WITH attached AS (
SELECT ppu.project_id AS attach_project_id,
ppu.partner_unit_id,
ppu.derive_unit_roles,
ppu.derive_grants_authority
FROM paliad.project_partner_units ppu
WHERE ppu.project_id = ANY($1::uuid[])
)
SELECT pum.user_id,
u.email, u.display_name, u.office,
jsonb_agg(DISTINCT jsonb_build_object(
'unit_id', a.partner_unit_id,
'unit_name', pu.name,
'unit_role', pum.unit_role
)) AS memberships,
bool_or(a.derive_grants_authority) AS derive_grants_authority
FROM attached a
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
JOIN paliad.users u ON u.id = pum.user_id
JOIN paliad.partner_units pu ON pu.id = a.partner_unit_id
WHERE pum.unit_role = ANY(a.derive_unit_roles)
GROUP BY pum.user_id, u.email, u.display_name, u.office
ORDER BY u.display_name`,
pq.StringArray(ancestorIDs))
if err != nil {
return nil, fmt.Errorf("list derived members: %w", err)
}
// jsonb_agg(DISTINCT …) doesn't support ORDER BY in the same call.
// Sort each member's memberships by unit_name in Go so the Herkunft
// column renders deterministically.
for i := range rows {
ms := rows[i].Memberships
for j := 1; j < len(ms); j++ {
for k := j; k > 0 && ms[k-1].UnitName > ms[k].UnitName; k-- {
ms[k-1], ms[k] = ms[k], ms[k-1]
}
}
}
return rows, nil
}
// ListDescendantStaffed returns users who are directly staffed on a
// descendant of the given project but not on the project itself or its
// ancestors. This is the new "Aus Unterprojekten" subsection on the Team
// tab — explicit Case-level staff that surfaces up to the parent for
// awareness.
//
// Excludes inherited rows (descendant team rows are by definition direct
// at their level — what we filter out are users already on this project
// or its ancestors so the same user doesn't appear in two subsections).
func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
rows := []models.ProjectTeamMemberWithUser{}
err := s.db.SelectContext(ctx, &rows, `
WITH descendants AS (
SELECT p.id, p.title
FROM paliad.projects p
WHERE p.id <> $1
AND $1 = ANY(string_to_array(p.path, '.')::uuid[])
),
ancestor_or_self AS (
SELECT pp.id
FROM paliad.projects target
JOIN paliad.projects pp
ON pp.id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = $1
),
descendant_rows AS (
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
d.title AS source_title
FROM paliad.project_teams pt
JOIN descendants d ON d.id = pt.project_id
WHERE pt.user_id NOT IN (
SELECT user_id FROM paliad.project_teams
WHERE project_id IN (SELECT id FROM ancestor_or_self)
)
),
dedup AS (
SELECT dr.*,
ROW_NUMBER() OVER (
PARTITION BY dr.user_id
ORDER BY dr.created_at ASC
) AS rn
FROM descendant_rows dr
)
SELECT d.id, d.project_id, d.user_id, d.role,
true AS inherited,
d.added_by, d.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
d.project_id AS inherited_from_id,
d.source_title AS inherited_from_title
FROM dedup d
JOIN paliad.users u ON u.id = d.user_id
WHERE d.rn = 1
ORDER BY d.role, u.display_name`,
projectID)
if err != nil {
return nil, fmt.Errorf("list descendant-staffed: %w", err)
}
return rows, nil
}
// EffectiveProjectRole returns (role, source) where source is one of:
// 'direct', 'ancestor', 'descendant', 'derived'. Used by the t-138
// approval ladder via canApprove() — Phase 3 of t-paliad-139 will wire
// this in.
//
// Resolution order:
// 1. direct (this project_teams row)
// 2. ancestor (project_teams on any ancestor — closest wins)
// 3. derived (partner_unit_members on an attached unit on this project
// or any ancestor — closest wins; only when derive_grants_authority=true)
// 4. descendant (rare for authority — explicit staffing on a descendant
// does NOT confer authority on the ancestor; returned for read use
// only, callers should prefer the higher tiers)
//
// Returns ("", "") when the user has no membership of any kind. This is a
// service-internal lookup — it does NOT visibility-check, since callers
// (the t-138 approval gate) need to know the caller's effective role even
// when visibility is being evaluated for the first time.
func (s *DerivationService) EffectiveProjectRole(ctx context.Context, userID, projectID uuid.UUID) (string, string, error) {
var path string
err := s.db.GetContext(ctx, &path,
`SELECT path FROM paliad.projects WHERE id = $1`, projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("read project path: %w", err)
}
ancestorIDs := pathToIDStrings(path)
// 1. Direct
var directRole string
err = s.db.GetContext(ctx, &directRole,
`SELECT role FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`,
projectID, userID)
if err == nil {
return directRole, "direct", nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("read direct role: %w", err)
}
// 2. Ancestor (closest wins via path distance — already root→self order
// in the path; pick the row whose project_id appears latest in the
// ancestorIDs array).
type ancRow struct {
Role string `db:"role"`
ProjID string `db:"project_id"`
Position int `db:"position"`
}
var ancestorMatches []ancRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &ancestorMatches, `
SELECT pt.role,
pt.project_id::text AS project_id,
array_position($1::uuid[], pt.project_id) AS position
FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.project_id = ANY($1::uuid[])
ORDER BY position DESC NULLS LAST
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read ancestor role: %w", err)
}
if len(ancestorMatches) > 0 {
return ancestorMatches[0].Role, "ancestor", nil
}
}
// 3. Derived with authority. Only authority-granting attachments count
// here; visibility-only derivation does not yield an effective role for
// approval purposes. The derived role is mapped from unit_role via
// approval_role_from_unit_role (a SQL function added in migration 055).
type derivedRow struct {
Role string `db:"role"`
}
var derived []derivedRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &derived, `
SELECT paliad.approval_role_from_unit_role(pum.unit_role) AS role
FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = $2
AND pum.unit_role = ANY(ppu.derive_unit_roles)
WHERE ppu.project_id = ANY($1::uuid[])
AND ppu.derive_grants_authority = true
ORDER BY paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) DESC
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read derived role: %w", err)
}
if len(derived) > 0 {
return derived[0].Role, "derived", nil
}
}
return "", "", nil
}

View File

@@ -0,0 +1,393 @@
package services
// FilterSpec is the structured filter description that drives the substrate
// (ViewService.RunSpec). Same shape lives in paliad.user_views.filter_spec
// (jsonb) and is composed by the frontend's view editor.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 Q2.
//
// Validation rules live alongside the type. Every public mutation path
// (POST/PUT /api/user-views, /api/views/{slug}/run with override params)
// runs FilterSpec.Validate() before touching the DB or executing a query.
import (
"encoding/json"
"fmt"
"slices"
"time"
"github.com/google/uuid"
)
// DataSource identifies one of the substrate's source rails.
type DataSource string
const (
SourceDeadline DataSource = "deadline"
SourceAppointment DataSource = "appointment"
SourceProjectEvent DataSource = "project_event"
SourceApprovalRequest DataSource = "approval_request"
)
// AllSources lists every supported source. Used by the validator and by
// the "all sources" UI affordance.
var AllSources = []DataSource{
SourceDeadline,
SourceAppointment,
SourceProjectEvent,
SourceApprovalRequest,
}
// SpecVersion is the on-the-wire schema version. Bumped when the shape
// changes incompatibly. Validator rejects unknown versions so we get a
// clear error instead of silent misinterpretation.
const SpecVersion = 1
// FilterSpec is the top-level filter description.
type FilterSpec struct {
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
}
// ScopeSpec narrows which projects contribute rows. Resolved at query
// time:
// - "all_visible" (default): no extra narrowing; RLS bounds to projects
// the caller can see (direct + descendant + derived per t-paliad-139).
// - "my_subtree": narrows to the caller's direct/descendant/derived
// staffing tree only. Equivalent to "all_visible" today (visibility
// == subtree); reserved for future when global_admin or shared
// visibility models add a wider tier.
// - explicit slice: exact project IDs. RLS still applies — IDs the
// caller can't see contribute zero rows (Q17 fail-open).
//
// PersonalOnly narrows deadline + appointment to caller-created rows.
// Mutually exclusive with explicit Projects (validator enforces). When
// PersonalOnly is true, project_event + approval_request sources still
// run with the standard visibility predicate — the "personal" framing
// only meaningfully applies to entity rows.
type ScopeSpec struct {
Projects ScopeProjects `json:"projects"`
PersonalOnly bool `json:"personal_only,omitempty"`
}
// ScopeProjects is the project-narrowing payload. Either a sentinel string
// ("all_visible" / "my_subtree") or an explicit []uuid.UUID.
//
// Encoded as a tagged shape on the wire so we don't have to teach a custom
// JSON unmarshaller a polymorphic field:
//
// {"mode": "all_visible"}
// {"mode": "my_subtree"}
// {"mode": "explicit", "ids": ["uuid", …]}
type ScopeProjects struct {
Mode ScopeMode `json:"mode"`
IDs []uuid.UUID `json:"ids,omitempty"`
}
type ScopeMode string
const (
ScopeAllVisible ScopeMode = "all_visible"
ScopeMySubtree ScopeMode = "my_subtree"
ScopeExplicit ScopeMode = "explicit"
)
// TimeSpec is the time-window narrowing. Horizon picks a relative window;
// when Horizon == "custom", From/To are honoured as an absolute window.
//
// Field selects which date column to filter against per source:
// - "auto" (default): each source picks — deadline → due_date,
// appointment → start_at, project_event → created_at,
// approval_request → requested_at.
// - "created_at": every source uses its own created_at column. Useful
// for "newly added events" views.
type TimeSpec struct {
Horizon TimeHorizon `json:"horizon"`
Field TimeField `json:"field,omitempty"`
From *time.Time `json:"from,omitempty"`
To *time.Time `json:"to,omitempty"`
}
type TimeHorizon string
const (
HorizonNext7d TimeHorizon = "next_7d"
HorizonNext30d TimeHorizon = "next_30d"
HorizonNext90d TimeHorizon = "next_90d"
HorizonPast30d TimeHorizon = "past_30d"
HorizonPast90d TimeHorizon = "past_90d"
HorizonAny TimeHorizon = "any"
HorizonAll TimeHorizon = "all"
HorizonCustom TimeHorizon = "custom"
)
type TimeField string
const (
FieldAuto TimeField = "auto"
FieldCreatedAt TimeField = "created_at"
)
// Predicates is the per-source narrowing payload. Empty fields mean
// "no narrowing" — never "exclude all".
type Predicates struct {
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
ProjectEvent *ProjectEventPredicates `json:"project_event,omitempty"`
ApprovalRequest *ApprovalRequestPredicates `json:"approval_request,omitempty"`
}
// DeadlinePredicates narrows the deadline rail.
type DeadlinePredicates struct {
Status []string `json:"status,omitempty"` // "pending" | "completed"
ApprovalStatus []string `json:"approval_status,omitempty"` // "approved" | "pending" | "legacy"
EventTypeIDs []uuid.UUID `json:"event_types,omitempty"`
IncludeUntyped bool `json:"include_untyped,omitempty"`
}
// AppointmentPredicates narrows the appointment rail.
type AppointmentPredicates struct {
ApprovalStatus []string `json:"approval_status,omitempty"`
AppointmentTypes []string `json:"appointment_types,omitempty"` // hearing/meeting/consultation/deadline_hearing
}
// ProjectEventPredicates narrows the audit/Verlauf rail. EventTypes is the
// curated list of project-event kinds (project_created, status_changed,
// deadline_created, …) — see KnownProjectEventKinds.
type ProjectEventPredicates struct {
EventTypes []string `json:"event_types,omitempty"`
}
// ApprovalRequestPredicates narrows the approval-inbox rail.
//
// ViewerRole shapes the per-row visibility:
// - "approver_eligible": rows where the caller is qualified to approve
// (the t-paliad-138 inbox path, extended by t-paliad-139 derivation).
// - "self_requested": rows the caller submitted.
// - "any_visible": every approval_request the caller can see via RLS
// (includes both of the above plus already-decided rows on visible
// projects). Used for retrospective/audit views.
type ApprovalRequestPredicates struct {
ViewerRole string `json:"viewer_role,omitempty"`
Status []string `json:"status,omitempty"` // "pending" | "approved" | "rejected" | "revoked"
EntityTypes []string `json:"entity_types,omitempty"` // "deadline" | "appointment"
}
// KnownProjectEventKinds is the curated set of project_event.event_type
// values the substrate exposes via the filter UI. Code-resident per
// design Q19.
//
// Mirrors the strings emitted by insertProjectEvent / insertProjectEventWithMeta
// in internal/services/*. New kinds appear here as they're added in code.
var KnownProjectEventKinds = []string{
"project_created",
"project_archived",
"project_reparented",
"project_type_changed",
"status_changed",
"deadline_created",
"deadline_completed",
"deadline_reopened",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"approval_decided",
"member_role_changed",
}
// validApprovalStatuses are the legal values for entity-side approval_status
// filters and request-side status filters respectively.
var (
validEntityApprovalStatuses = []string{"approved", "pending", "legacy"}
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked"}
validApprovalEntityTypes = []string{"deadline", "appointment"}
validApprovalViewerRoles = []string{"approver_eligible", "self_requested", "any_visible"}
validDeadlineStatuses = []string{"pending", "completed"}
validAppointmentTypes = []string{"hearing", "meeting", "consultation", "deadline_hearing"}
)
// Validate runs the full FilterSpec contract. Returns a wrapped
// ErrInvalidInput on the first violation.
//
// The validator is the single gate that protects the DB from malformed
// jsonb. Both POST /api/user-views and runtime-override params on
// GET /api/views/{slug}/run go through it.
func (s *FilterSpec) Validate() error {
if s == nil {
return fmt.Errorf("%w: filter_spec is required", ErrInvalidInput)
}
if s.Version != SpecVersion {
return fmt.Errorf("%w: filter_spec version %d not supported (expected %d)", ErrInvalidInput, s.Version, SpecVersion)
}
if len(s.Sources) == 0 {
return fmt.Errorf("%w: at least one source must be selected", ErrInvalidInput)
}
seen := make(map[DataSource]bool, len(s.Sources))
for _, src := range s.Sources {
if !isKnownSource(src) {
return fmt.Errorf("%w: unknown source %q", ErrInvalidInput, src)
}
if seen[src] {
return fmt.Errorf("%w: duplicate source %q", ErrInvalidInput, src)
}
seen[src] = true
}
if err := s.Scope.validate(); err != nil {
return err
}
if err := s.Time.validate(s.Scope); err != nil {
return err
}
for src, preds := range s.Predicates {
if !isKnownSource(src) {
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
}
if !seen[src] {
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
}
if err := preds.validate(); err != nil {
return err
}
}
return nil
}
func (s *ScopeSpec) validate() error {
switch s.Projects.Mode {
case ScopeAllVisible, ScopeMySubtree:
if len(s.Projects.IDs) > 0 {
return fmt.Errorf("%w: scope.projects.ids must be empty when mode=%q", ErrInvalidInput, s.Projects.Mode)
}
case ScopeExplicit:
if len(s.Projects.IDs) == 0 {
return fmt.Errorf("%w: scope.projects.ids must be non-empty when mode=%q", ErrInvalidInput, ScopeExplicit)
}
if s.PersonalOnly {
return fmt.Errorf("%w: scope.personal_only cannot be combined with explicit projects", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: unknown scope.projects.mode %q", ErrInvalidInput, s.Projects.Mode)
}
return nil
}
func (t *TimeSpec) validate(scope ScopeSpec) error {
switch t.Horizon {
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
HorizonPast30d, HorizonPast90d, HorizonAny:
// fine
case HorizonAll:
// Q26: reject "all" unless scope.projects is explicit. Performance
// safeguard — an unbounded substrate query across every visible
// project is the worst case and we want it gated by intent.
if scope.Projects.Mode != ScopeExplicit {
return fmt.Errorf("%w: time.horizon=%q requires scope.projects.mode=%q", ErrInvalidInput, HorizonAll, ScopeExplicit)
}
case HorizonCustom:
if t.From == nil || t.To == nil {
return fmt.Errorf("%w: time.horizon=%q requires both from and to", ErrInvalidInput, HorizonCustom)
}
if !t.To.After(*t.From) {
return fmt.Errorf("%w: time.to must be strictly after time.from", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: unknown time.horizon %q", ErrInvalidInput, t.Horizon)
}
switch t.Field {
case "", FieldAuto, FieldCreatedAt:
// fine
default:
return fmt.Errorf("%w: unknown time.field %q", ErrInvalidInput, t.Field)
}
return nil
}
func (p *Predicates) validate() error {
if p.Deadline != nil {
if err := validateStringEnum("deadline.status", p.Deadline.Status, validDeadlineStatuses); err != nil {
return err
}
if err := validateStringEnum("deadline.approval_status", p.Deadline.ApprovalStatus, validEntityApprovalStatuses); err != nil {
return err
}
}
if p.Appointment != nil {
if err := validateStringEnum("appointment.approval_status", p.Appointment.ApprovalStatus, validEntityApprovalStatuses); err != nil {
return err
}
if err := validateStringEnum("appointment.appointment_types", p.Appointment.AppointmentTypes, validAppointmentTypes); err != nil {
return err
}
}
if p.ProjectEvent != nil {
if err := validateStringEnum("project_event.event_types", p.ProjectEvent.EventTypes, KnownProjectEventKinds); err != nil {
return err
}
}
if p.ApprovalRequest != nil {
if r := p.ApprovalRequest.ViewerRole; r != "" {
if err := validateStringEnum("approval_request.viewer_role", []string{r}, validApprovalViewerRoles); err != nil {
return err
}
}
if err := validateStringEnum("approval_request.status", p.ApprovalRequest.Status, validRequestStatuses); err != nil {
return err
}
if err := validateStringEnum("approval_request.entity_types", p.ApprovalRequest.EntityTypes, validApprovalEntityTypes); err != nil {
return err
}
}
return nil
}
func validateStringEnum(field string, values, allowed []string) error {
for _, v := range values {
if !slices.Contains(allowed, v) {
return fmt.Errorf("%w: unknown %s value %q", ErrInvalidInput, field, v)
}
}
return nil
}
func isKnownSource(s DataSource) bool {
return slices.Contains(AllSources, s)
}
// DefaultFilterSpec returns a minimal valid spec — used as the seed when
// the editor opens with a blank canvas.
func DefaultFilterSpec() FilterSpec {
return FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
}
}
// MarshalFilterSpec is a convenience wrapper used by handlers when storing
// the spec into the jsonb column. Pre-validates so a malformed spec never
// reaches the DB.
func MarshalFilterSpec(s FilterSpec) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
return json.Marshal(s)
}
// UnmarshalFilterSpec parses + validates a stored / submitted spec.
func UnmarshalFilterSpec(b []byte) (FilterSpec, error) {
var s FilterSpec
if err := json.Unmarshal(b, &s); err != nil {
return FilterSpec{}, fmt.Errorf("%w: filter_spec malformed: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return FilterSpec{}, err
}
return s, nil
}

View File

@@ -0,0 +1,263 @@
package services
// Pure-Go tests for FilterSpec — no DB touch. Cover happy path, every
// reject branch, and the cross-field constraints (Q26 horizon-clamp,
// scope mode/IDs invariants).
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
)
func validBaseSpec() FilterSpec {
return FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
}
}
func TestFilterSpec_HappyPath(t *testing.T) {
s := validBaseSpec()
if err := s.Validate(); err != nil {
t.Fatalf("base spec should validate: %v", err)
}
}
func TestFilterSpec_DefaultIsValid(t *testing.T) {
s := DefaultFilterSpec()
if err := s.Validate(); err != nil {
t.Fatalf("DefaultFilterSpec must validate: %v", err)
}
}
func TestFilterSpec_Version(t *testing.T) {
s := validBaseSpec()
s.Version = 2
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown version must reject with ErrInvalidInput, got %v", err)
}
}
func TestFilterSpec_NoSources(t *testing.T) {
s := validBaseSpec()
s.Sources = nil
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty sources must reject, got %v", err)
}
}
func TestFilterSpec_UnknownSource(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{"bogus"}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown source must reject, got %v", err)
}
}
func TestFilterSpec_DuplicateSource(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline, SourceDeadline}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("duplicate source must reject, got %v", err)
}
}
func TestFilterSpec_AllSourcesAccepted(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline, SourceAppointment, SourceProjectEvent, SourceApprovalRequest}
if err := s.Validate(); err != nil {
t.Fatalf("all four sources together must validate: %v", err)
}
}
func TestFilterSpec_ScopeAllVisibleRejectsIDs(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects.IDs = []uuid.UUID{uuid.New()}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("ids on all_visible mode must reject, got %v", err)
}
}
func TestFilterSpec_ScopeExplicitNeedsIDs(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("explicit mode without ids must reject, got %v", err)
}
}
func TestFilterSpec_ScopeExplicitWithIDsValidates(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
if err := s.Validate(); err != nil {
t.Fatalf("explicit mode + ids must validate: %v", err)
}
}
func TestFilterSpec_PersonalOnlyConflictsWithExplicit(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
s.Scope.PersonalOnly = true
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("personal_only + explicit projects must reject, got %v", err)
}
}
func TestFilterSpec_HorizonAllRejectsWithoutExplicit(t *testing.T) {
// Q26 lock-in: horizon=all is rejected unless scope.projects.mode=explicit.
s := validBaseSpec()
s.Time.Horizon = HorizonAll
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("horizon=all without explicit projects must reject, got %v", err)
}
}
func TestFilterSpec_HorizonAllAcceptsWithExplicit(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
s.Time.Horizon = HorizonAll
if err := s.Validate(); err != nil {
t.Fatalf("horizon=all with explicit projects must validate: %v", err)
}
}
func TestFilterSpec_HorizonCustomNeedsBothBounds(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("custom horizon without bounds must reject, got %v", err)
}
}
func TestFilterSpec_HorizonCustomRejectsInvertedRange(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
earlier := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// to before from
s.Time.From = &later
s.Time.To = &earlier
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("inverted from/to must reject, got %v", err)
}
}
func TestFilterSpec_HorizonCustomAcceptsValidRange(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s.Time.From = &from
s.Time.To = &to
if err := s.Validate(); err != nil {
t.Fatalf("valid custom range must accept: %v", err)
}
}
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline}
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("predicates on unselected source must reject, got %v", err)
}
}
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Predicates = map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown deadline.status must reject, got %v", err)
}
}
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = append(s.Sources, SourceAppointment)
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown appointment_type must reject, got %v", err)
}
}
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceProjectEvent}
s.Predicates = map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown project_event kind must reject, got %v", err)
}
}
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown viewer_role must reject, got %v", err)
}
}
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown approval_request.status must reject, got %v", err)
}
}
func TestFilterSpec_RoundTripJSON(t *testing.T) {
original := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceApprovalRequest},
Scope: ScopeSpec{
Projects: ScopeProjects{Mode: ScopeAllVisible},
PersonalOnly: false,
},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{
Status: []string{"pending"},
ApprovalStatus: []string{"approved", "pending"},
}},
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
}
b, err := MarshalFilterSpec(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := UnmarshalFilterSpec(b)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.Version != original.Version {
t.Errorf("version mismatch: %d vs %d", parsed.Version, original.Version)
}
if len(parsed.Sources) != len(original.Sources) {
t.Errorf("sources mismatch: %v vs %v", parsed.Sources, original.Sources)
}
}

View File

@@ -421,6 +421,13 @@ func hostnameForHelo() string {
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
// (umlauts) render correctly in every client.
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
return buildMIMEWithReplyTo(from, fromName, "", to, subject, htmlBody, textBody)
}
// buildMIMEWithReplyTo is buildMIME plus an optional Reply-To header.
// Bulk-broadcast email uses this so replies route to the human sender even
// though From: stays on the SMTP infrastructure address.
func buildMIMEWithReplyTo(from, fromName, replyTo, to, subject, htmlBody, textBody string) []byte {
boundary := "paliad-mixed-" + randBoundary()
fromHeader := from
if fromName != "" {
@@ -428,6 +435,9 @@ func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
}
var b bytes.Buffer
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
if replyTo != "" {
fmt.Fprintf(&b, "Reply-To: %s\r\n", replyTo)
}
fmt.Fprintf(&b, "To: %s\r\n", to)
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))

View File

@@ -0,0 +1,127 @@
// markdown.go — minimal Markdown → safe HTML converter for broadcast emails.
//
// Paliad doesn't pull in a third-party Markdown library — the body subset
// senders need is small and predictable, so we render it inline. Inputs are
// HTML-escaped first; the renderer then re-introduces a small whitelist of
// inline tags (<strong>, <em>, <code>, <a>) and block elements (<p>, <ul>,
// <li>, <br>) for the patterns it recognises. Anything we don't recognise
// stays escaped, so an attacker who tries to slip a <script> tag through
// the compose modal sees a literal "&lt;script&gt;" in the rendered email.
//
// Supported syntax:
// - Paragraphs separated by blank lines.
// - Single line break inside a paragraph → <br>.
// - **bold** → <strong>bold</strong>
// - _italic_ or *italic* → <em>italic</em>
// - `inline code` → <code>inline code</code>
// - [text](https://link) → <a href="...">text</a>
// - Lines starting with "- " or "* " → <ul><li>...</li></ul>
//
// Out-of-scope (intentional, per t-paliad-147 v1):
// - Headings, blockquotes, ordered lists, fenced code blocks, images,
// tables. These can be added on demand without changing the contract.
package services
import (
"fmt"
"html"
"regexp"
"strings"
)
// renderMarkdownSafe converts Markdown to HTML. Output is safe for direct
// embedding in an HTML email body: every byte of input is escaped before
// the markdown post-processor runs, and the inline rewriter only re-emits
// a small whitelist of tags.
func renderMarkdownSafe(src string) string {
src = strings.ReplaceAll(src, "\r\n", "\n")
src = strings.ReplaceAll(src, "\r", "\n")
// Split into paragraphs on blank lines.
paragraphs := strings.Split(src, "\n\n")
var out strings.Builder
for _, raw := range paragraphs {
p := strings.TrimSpace(raw)
if p == "" {
continue
}
// Bullet lists: every line starts with "- " or "* ".
if isBulletList(p) {
out.WriteString("<ul>\n")
for _, line := range strings.Split(p, "\n") {
item := strings.TrimSpace(line)
if len(item) >= 2 && (item[:2] == "- " || item[:2] == "* ") {
item = strings.TrimSpace(item[2:])
}
out.WriteString(" <li>")
out.WriteString(renderInline(item))
out.WriteString("</li>\n")
}
out.WriteString("</ul>\n")
continue
}
// Plain paragraph. Single-newline within → <br>.
lines := strings.Split(p, "\n")
out.WriteString("<p>")
for i, line := range lines {
if i > 0 {
out.WriteString("<br>\n")
}
out.WriteString(renderInline(strings.TrimSpace(line)))
}
out.WriteString("</p>\n")
}
return out.String()
}
func isBulletList(p string) bool {
for _, line := range strings.Split(p, "\n") {
t := strings.TrimSpace(line)
if len(t) < 2 {
return false
}
if t[:2] != "- " && t[:2] != "* " {
return false
}
}
return true
}
var (
mdLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^\s)]+)\)`)
mdBoldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
mdItalRE1 = regexp.MustCompile(`(^|[^\w])_([^_]+)_($|[^\w])`)
mdItalRE2 = regexp.MustCompile(`(^|[^\w*])\*([^*]+)\*($|[^\w*])`)
mdCodeRE = regexp.MustCompile("`([^`]+)`")
)
// renderInline applies inline markdown to one line. The input is escaped
// first; replacements re-emit whitelisted tags.
func renderInline(line string) string {
s := html.EscapeString(line)
// Order matters: links first (they wrap text+URL), then bold (which is
// **…** and would otherwise be split by the italic *…* rule), then
// italics, then code.
s = mdLinkRE.ReplaceAllStringFunc(s, func(m string) string {
matches := mdLinkRE.FindStringSubmatch(m)
if len(matches) != 3 {
return m
}
text, url := matches[1], matches[2]
// URL is already escaped by html.EscapeString above; href quoting
// also needs the &-form so screen readers don't choke.
return fmt.Sprintf(`<a href="%s">%s</a>`, url, text)
})
s = mdBoldRE.ReplaceAllString(s, `<strong>$1</strong>`)
s = mdItalRE1.ReplaceAllString(s, `$1<em>$2</em>$3`)
s = mdItalRE2.ReplaceAllString(s, `$1<em>$2</em>$3`)
s = mdCodeRE.ReplaceAllString(s, `<code>$1</code>`)
return s
}
// escapeHTML is a thin alias used by senderSignature so the broadcast file
// doesn't need to import html directly.
func escapeHTML(s string) string {
return html.EscapeString(s)
}

View File

@@ -324,6 +324,58 @@ func (s *PartnerUnitService) RemoveMember(ctx context.Context, callerID, partner
return tx.Commit()
}
// SetMemberRole updates the unit_role column on a (partner_unit, user)
// membership. Admin-only. Validates the role against the migration-055 CHECK.
// Emits 'member_role_changed' carrying the prior + new role values so the
// audit trail captures the transition.
func (s *PartnerUnitService) SetMemberRole(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID, role string) error {
if err := s.requireAdmin(ctx, callerID); err != nil {
return err
}
if !isValidUnitRole(role) {
return fmt.Errorf("%w: invalid unit_role %q", ErrInvalidInput, role)
}
unit, err := s.GetByID(ctx, partnerUnitID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var prior string
err = tx.GetContext(ctx, &prior,
`SELECT unit_role FROM paliad.partner_unit_members
WHERE partner_unit_id = $1 AND user_id = $2`,
partnerUnitID, userID)
if err != nil {
return fmt.Errorf("read prior unit_role: %w", err)
}
if prior == role {
return tx.Commit()
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.partner_unit_members
SET unit_role = $3
WHERE partner_unit_id = $1 AND user_id = $2`,
partnerUnitID, userID, role); err != nil {
return fmt.Errorf("update unit_role: %w", err)
}
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_role_changed", map[string]any{
"user_id": userID,
"old_role": prior,
"new_role": role,
}); err != nil {
return err
}
return tx.Commit()
}
// AddMemberTx is the same as AddMember but runs inside the caller's tx and
// skips the admin gate (caller has already authorised the parent operation).
// Used by user_service.OnboardUser to insert a partner_unit membership in
@@ -362,15 +414,40 @@ func (s *PartnerUnitService) AddMemberTx(ctx context.Context, tx *sqlx.Tx, actor
// PartnerUnitMemberDetail is one user's membership row enriched with display
// fields for the admin/team UIs.
//
// UnitRole (added by t-paliad-139 / migration 055) is the per-unit role
// distinction used by the derivation rule: a unit attached to a project
// auto-derives its members whose unit_role is in the attachment's
// derive_unit_roles set (default {pa, senior_pa}). Possible values:
// 'lead' | 'attorney' | 'senior_pa' | 'pa' | 'paralegal'. Defaults to
// 'attorney' for every pre-055 row.
type PartnerUnitMemberDetail struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Email string `db:"email" json:"email"`
DisplayName string `db:"display_name" json:"display_name"`
Office string `db:"office" json:"office"`
JobTitle *string `db:"job_title" json:"job_title"`
UnitRole string `db:"unit_role" json:"unit_role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// PartnerUnitMemberRole values (mirror migration 055 CHECK constraint).
const (
UnitRoleLead = "lead"
UnitRoleAttorney = "attorney"
UnitRoleSeniorPA = "senior_pa"
UnitRolePA = "pa"
UnitRoleParalegal = "paralegal"
)
func isValidUnitRole(r string) bool {
switch r {
case UnitRoleLead, UnitRoleAttorney, UnitRoleSeniorPA, UnitRolePA, UnitRoleParalegal:
return true
}
return false
}
// ListMembers returns users in the PartnerUnit, enriched with display fields.
//
// INNER JOIN on paliad.users: partner_unit_members.user_id FKs auth.users, so
@@ -381,7 +458,7 @@ type PartnerUnitMemberDetail struct {
func (s *PartnerUnitService) ListMembers(ctx context.Context, partnerUnitID uuid.UUID) ([]PartnerUnitMemberDetail, error) {
var rows []PartnerUnitMemberDetail
err := s.db.SelectContext(ctx, &rows,
`SELECT pum.user_id, pum.created_at,
`SELECT pum.user_id, pum.created_at, pum.unit_role,
u.email, u.display_name, u.office, u.job_title
FROM paliad.partner_unit_members pum
JOIN paliad.users u ON u.id = pum.user_id
@@ -431,7 +508,7 @@ func (s *PartnerUnitService) ListWithMembers(ctx context.Context) ([]PartnerUnit
}
var members []memberRow
err = s.db.SelectContext(ctx, &members,
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at,
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at, pum.unit_role,
u.email, u.display_name, u.office, u.job_title
FROM paliad.partner_unit_members pum
JOIN paliad.users u ON u.id = pum.user_id

View File

@@ -18,11 +18,24 @@ import (
// rows attached to that Project AND every descendant Project (Litigation,
// Patent, Case below it). The descendant set is derived from
// paliad.projects.path, which the schema's path trigger keeps in sync from
// parent_id. The check is exercised against four entry points:
// - DeadlineService.ListVisibleForUser
// - DeadlineService.SummaryCounts
// - AppointmentService.ListVisibleForUser
// - EventService.ListVisibleForUser (union of deadlines + appointments)
// parent_id.
//
// t-paliad-139 extends the contract to the per-project narrow read paths
// that the /projects/{id} detail surfaces use:
// - DeadlineService.ListForProject
// - AppointmentService.ListForProject
// - ProjectService.ListEvents (audit / Verlauf)
// All three default to subtree aggregation (directOnly=false). When
// directOnly=true, only rows with project_id == filter are returned.
//
// The check is exercised against:
// - DeadlineService.ListVisibleForUser (union, t-124)
// - DeadlineService.SummaryCounts (union, t-124)
// - AppointmentService.ListVisibleForUser (union, t-124)
// - EventService.ListVisibleForUser (union, t-124)
// - DeadlineService.ListForProject (per-project narrow, t-139)
// - AppointmentService.ListForProject (per-project narrow, t-139)
// - ProjectService.ListEvents (per-project narrow, t-139)
//
// Skipped when TEST_DATABASE_URL is unset.
func TestProjectFilter_IncludesDescendants(t *testing.T) {
@@ -275,6 +288,72 @@ func TestProjectFilter_IncludesDescendants(t *testing.T) {
if gotEA != c.wantAppointmts {
t.Errorf("events.ListVisibleForUser appointments: got %d, want %d", gotEA, c.wantAppointmts)
}
// t-paliad-139: per-project narrow paths must match the union path
// when directOnly=false (subtree default), and must collapse to
// just the direct row when directOnly=true.
// DeadlineService.ListForProject — subtree (default).
dlfp, err := deadlines.ListForProject(ctx, adminID, pid, false)
if err != nil {
t.Fatalf("deadlines.ListForProject subtree: %v", err)
}
gotDFP := 0
for _, r := range dlfp {
if seedDeadlines[r.ID] {
gotDFP++
}
}
if gotDFP != c.wantDeadlines {
t.Errorf("deadlines.ListForProject subtree: got %d, want %d", gotDFP, c.wantDeadlines)
}
// DeadlineService.ListForProject — directOnly=true.
dlfpDirect, err := deadlines.ListForProject(ctx, adminID, pid, true)
if err != nil {
t.Fatalf("deadlines.ListForProject direct: %v", err)
}
gotDFPDirect := 0
for _, r := range dlfpDirect {
if seedDeadlines[r.ID] {
gotDFPDirect++
}
}
// directOnly: only the deadline whose project_id == filter is
// returned (the seeded direct-row at this level).
if gotDFPDirect != 1 {
t.Errorf("deadlines.ListForProject directOnly: got %d, want 1", gotDFPDirect)
}
// AppointmentService.ListForProject — subtree (default).
alfp, err := appointments.ListForProject(ctx, adminID, pid, false)
if err != nil {
t.Fatalf("appointments.ListForProject subtree: %v", err)
}
gotAFP := 0
for _, r := range alfp {
if seedAppointments[r.ID] {
gotAFP++
}
}
if gotAFP != c.wantAppointmts {
t.Errorf("appointments.ListForProject subtree: got %d, want %d", gotAFP, c.wantAppointmts)
}
// AppointmentService.ListForProject — directOnly=true.
alfpDirect, err := appointments.ListForProject(ctx, adminID, pid, true)
if err != nil {
t.Fatalf("appointments.ListForProject direct: %v", err)
}
gotAFPDirect := 0
for _, r := range alfpDirect {
if seedAppointments[r.ID] {
gotAFPDirect++
}
}
if gotAFPDirect != 1 {
t.Errorf("appointments.ListForProject directOnly: got %d, want 1", gotAFPDirect)
}
})
}
}

View File

@@ -704,7 +704,14 @@ const DefaultEventsPageLimit = 50
// ListEvents returns the audit trail for the Project, newest first, with
// cursor pagination (before = uuid of last seen event).
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjectEvent, error) {
//
// When directOnly is false (default), the result aggregates events from
// the Project itself AND every descendant Project (per the t-paliad-139
// hierarchy aggregation contract — Verlauf on a Client should show the
// matter's complete history, not just rows attached at the root). When
// directOnly is true, only events whose project_id exactly equals the
// filter are returned.
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int, directOnly bool) ([]models.ProjectEvent, error) {
if _, err := s.GetByID(ctx, userID, id); err != nil {
return nil, err
}
@@ -718,16 +725,27 @@ func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, b
if before != nil {
beforeArg = *before
}
var projectFilter string
if directOnly {
projectFilter = `project_id = $1`
} else {
// Inner alias `pp` to avoid shadowing the outer `p` JOIN below.
projectFilter = `project_id IN (
SELECT pp.id FROM paliad.projects pp
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[]))`
}
var events []models.ProjectEvent
err := s.db.SelectContext(ctx, &events,
`SELECT id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at
FROM paliad.project_events
WHERE project_id = $1
AND ($2::uuid IS NULL OR (created_at, id) < (
`SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date,
pe.created_by, pe.metadata, pe.created_at, pe.updated_at,
p.title AS project_title
FROM paliad.project_events pe
LEFT JOIN paliad.projects p ON p.id = pe.project_id
WHERE pe.`+projectFilter+`
AND ($2::uuid IS NULL OR (pe.created_at, pe.id) < (
SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid
))
ORDER BY created_at DESC, id DESC
ORDER BY pe.created_at DESC, pe.id DESC
LIMIT $3`, id, beforeArg, limit)
if err != nil {
return nil, fmt.Errorf("list project events: %w", err)

View File

@@ -0,0 +1,207 @@
package services
// RenderSpec is the structured render description that drives how a view
// is presented. Same shape lives in paliad.user_views.render_spec (jsonb)
// and is composed by the frontend's view editor.
//
// Design: docs/design-data-display-model-2026-05-06.md §4 (Q4-Q5).
//
// Q4 lock-in (m, 2026-05-07): three shapes — list, cards, calendar.
// "Activity" is content selection (sources + filters), not visualisation —
// achieved via shape="list", list.density="compact", list.columns=
// ["time","actor","title","project"]. Same component, different config.
import (
"encoding/json"
"fmt"
"slices"
)
// RenderShape identifies which presentation component a view uses.
type RenderShape string
const (
ShapeList RenderShape = "list"
ShapeCards RenderShape = "cards"
ShapeCalendar RenderShape = "calendar"
)
// AllShapes lists every supported shape. Used by the validator and by
// the in-page shape switcher.
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
// RenderSpec is the top-level render description.
//
// Per-shape config blocks are kept on save even when a different shape
// is selected, so flipping back to a previously-used shape preserves
// its tweaks (Q5 design decision).
type RenderSpec struct {
Shape RenderShape `json:"shape"`
List *ListConfig `json:"list,omitempty"`
Cards *CardsConfig `json:"cards,omitempty"`
Calendar *CalendarConfig `json:"calendar,omitempty"`
}
// ListConfig is the per-shape config for shape=list. Powers both the
// /events table look (density=comfortable) and the activity-feed look
// (density=compact + actor/time columns).
type ListConfig struct {
Columns []string `json:"columns,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
Density ListDensity `json:"density,omitempty"`
}
// CardsConfig is the per-shape config for shape=cards.
type CardsConfig struct {
GroupBy CardsGroupBy `json:"group_by,omitempty"`
Sort SortOrder `json:"sort,omitempty"`
ShowEmptyDays bool `json:"show_empty_days,omitempty"`
}
// CalendarConfig is the per-shape config for shape=calendar.
type CalendarConfig struct {
DefaultView CalendarView `json:"default_view,omitempty"`
ShowWeekends bool `json:"show_weekends,omitempty"`
}
type SortOrder string
const (
SortDateAsc SortOrder = "date_asc"
SortDateDesc SortOrder = "date_desc"
)
type ListDensity string
const (
DensityComfortable ListDensity = "comfortable"
DensityCompact ListDensity = "compact"
)
type CardsGroupBy string
const (
CardsGroupByDay CardsGroupBy = "day"
CardsGroupByWeek CardsGroupBy = "week"
CardsGroupByNone CardsGroupBy = "none"
)
type CalendarView string
const (
CalendarMonth CalendarView = "month"
CalendarWeek CalendarView = "week"
)
// KnownListColumns is the curated set of column keys the list-shape
// renderer understands. Validator rejects anything else so a typo in
// the editor surfaces immediately.
var KnownListColumns = []string{
"date", "time", "title", "project", "actor", "status",
"rule", "event_type", "location", "appointment_type",
"approval_status", "decided_by", "kind",
}
// Validate runs the full RenderSpec contract.
func (s *RenderSpec) Validate() error {
if s == nil {
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
}
switch s.Shape {
case ShapeList, ShapeCards, ShapeCalendar:
// fine
default:
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
}
if s.List != nil {
if err := s.List.validate(); err != nil {
return err
}
}
if s.Cards != nil {
if err := s.Cards.validate(); err != nil {
return err
}
}
if s.Calendar != nil {
if err := s.Calendar.validate(); err != nil {
return err
}
}
return nil
}
func (c *ListConfig) validate() error {
for _, col := range c.Columns {
if !slices.Contains(KnownListColumns, col) {
return fmt.Errorf("%w: unknown list.columns value %q", ErrInvalidInput, col)
}
}
switch c.Sort {
case "", SortDateAsc, SortDateDesc:
default:
return fmt.Errorf("%w: unknown list.sort %q", ErrInvalidInput, c.Sort)
}
switch c.Density {
case "", DensityComfortable, DensityCompact:
default:
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
}
return nil
}
func (c *CardsConfig) validate() error {
switch c.GroupBy {
case "", CardsGroupByDay, CardsGroupByWeek, CardsGroupByNone:
default:
return fmt.Errorf("%w: unknown cards.group_by %q", ErrInvalidInput, c.GroupBy)
}
switch c.Sort {
case "", SortDateAsc, SortDateDesc:
default:
return fmt.Errorf("%w: unknown cards.sort %q", ErrInvalidInput, c.Sort)
}
return nil
}
func (c *CalendarConfig) validate() error {
switch c.DefaultView {
case "", CalendarMonth, CalendarWeek:
default:
return fmt.Errorf("%w: unknown calendar.default_view %q", ErrInvalidInput, c.DefaultView)
}
return nil
}
// DefaultRenderSpec returns a minimal valid spec — used as the seed when
// the editor opens with a blank canvas.
func DefaultRenderSpec() RenderSpec {
return RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Sort: SortDateAsc,
Density: DensityComfortable,
},
}
}
// MarshalRenderSpec validates + encodes for storage.
func MarshalRenderSpec(s RenderSpec) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
return json.Marshal(s)
}
// UnmarshalRenderSpec parses + validates.
func UnmarshalRenderSpec(b []byte) (RenderSpec, error) {
var s RenderSpec
if err := json.Unmarshal(b, &s); err != nil {
return RenderSpec{}, fmt.Errorf("%w: render_spec malformed: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return RenderSpec{}, err
}
return s, nil
}

View File

@@ -0,0 +1,103 @@
package services
// Pure-Go tests for RenderSpec.
import (
"errors"
"testing"
)
func TestRenderSpec_HappyPath(t *testing.T) {
s := DefaultRenderSpec()
if err := s.Validate(); err != nil {
t.Fatalf("default render spec must validate: %v", err)
}
}
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
for _, sh := range cases {
t.Run(string(sh), func(t *testing.T) {
s := RenderSpec{Shape: sh}
if err := s.Validate(); err != nil {
t.Fatalf("shape %q must validate: %v", sh, err)
}
})
}
}
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
s := RenderSpec{Shape: "kanban"}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown shape must reject, got %v", err)
}
}
func TestRenderSpec_ListColumnEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: []string{"date", "bogus"}}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown list column must reject, got %v", err)
}
}
func TestRenderSpec_KnownListColumnsAccepted(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: KnownListColumns}}
if err := s.Validate(); err != nil {
t.Fatalf("known columns must validate: %v", err)
}
}
func TestRenderSpec_ListSortEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Sort: "weird"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown sort must reject, got %v", err)
}
}
func TestRenderSpec_ListDensityEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Density: "huge"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown density must reject, got %v", err)
}
}
func TestRenderSpec_CardsGroupByEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeCards, Cards: &CardsConfig{GroupBy: "month"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown group_by must reject, got %v", err)
}
}
func TestRenderSpec_CalendarViewEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeCalendar, Calendar: &CalendarConfig{DefaultView: "year"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown default_view must reject, got %v", err)
}
}
func TestRenderSpec_RoundTrip(t *testing.T) {
original := RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Columns: []string{"time", "actor", "title", "project"},
Sort: SortDateDesc,
Density: DensityCompact,
},
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
Calendar: &CalendarConfig{DefaultView: CalendarMonth},
}
b, err := MarshalRenderSpec(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := UnmarshalRenderSpec(b)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.Shape != original.Shape {
t.Errorf("shape mismatch: %v vs %v", parsed.Shape, original.Shape)
}
if parsed.List == nil || parsed.List.Density != DensityCompact {
t.Errorf("list config not preserved: %+v", parsed.List)
}
}

View File

@@ -0,0 +1,208 @@
package services
// SystemView is a code-resident view definition. The four system pages
// (dashboard / agenda / events / inbox) resolve to one of these when
// they want to consume the substrate as if they were a Custom View.
//
// Design: docs/design-data-display-model-2026-05-06.md §5 Q8.
//
// Q8 lock-in: defaults are config-as-code, not seeded rows in
// paliad.user_views. Their slugs are reserved (validator rejects
// matching user-view slugs).
import (
"slices"
)
// SystemView is the in-process projection used by the substrate's
// SystemView callers. It mirrors the persisted user-view shape but
// never round-trips through the DB.
type SystemView struct {
Slug string // matches the system-page URL ("/dashboard" → "dashboard")
Name string // display label (kept English here; UI re-translates via i18n)
Filter FilterSpec // canonical filter the page resolves to today
Render RenderSpec // canonical render shape
}
// DashboardSystemView returns the SystemView definition for /dashboard.
//
// Note: /dashboard is composed of multiple sections (5-bucket summary +
// matter card + two-column lists + activity feed). It does NOT resolve
// to a single FilterSpec/RenderSpec — Phase B will compose several
// SystemView resolutions into the dashboard page. This entry exists so
// the slug is known to the reserved-list and so future composition has
// a stable hook.
func DashboardSystemView() SystemView {
return SystemView{
Slug: "dashboard",
Name: "Dashboard",
// Placeholder filter — the dashboard composes multiple queries
// in Phase B; this single spec covers the activity feed only.
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldCreatedAt},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityCompact,
Sort: SortDateDesc,
Columns: []string{"time", "actor", "title", "project"},
},
},
}
}
// AgendaSystemView returns the SystemView definition for /agenda — a
// day-grouped feed of upcoming deadlines + appointments.
func AgendaSystemView() SystemView {
return SystemView{
Slug: "agenda",
Name: "Agenda",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
},
},
Render: RenderSpec{
Shape: ShapeCards,
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
},
}
}
// EventsSystemView returns the SystemView definition for /events — the
// table view over deadlines + appointments. The legacy URL keeps a
// per-type chip toggle; this SystemView reflects the "all" tab default.
func EventsSystemView() SystemView {
return SystemView{
Slug: "events",
Name: "Events",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
func InboxRequesterSystemView() SystemView {
return SystemView{
Slug: "inbox-mine",
Name: "Inbox (mine)",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "self_requested",
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// AllSystemViews returns every system-defined view in registration order.
// Used by the reserved-slug list and by future Phase B composition.
func AllSystemViews() []SystemView {
return []SystemView{
DashboardSystemView(),
AgendaSystemView(),
EventsSystemView(),
InboxSystemView(),
InboxRequesterSystemView(),
}
}
// reservedUserViewSlugs is the static list of slugs the user-view CRUD
// rejects on create / update. Includes the SystemView slugs plus URLs
// the application owns at the top level (admin, settings, login, …).
//
// Q23 lock-in (m, 2026-05-07): list as drafted.
var reservedUserViewSlugs = []string{
// SystemView slugs:
"dashboard", "agenda", "events", "inbox", "inbox-mine",
// /views/* routes:
"new", "edit",
// Top-level application URLs:
"tools", "admin", "settings", "login", "logout",
"projects", "team", "courts", "glossary", "links",
"downloads", "checklists", "views", "changelog",
}
// IsReservedUserViewSlug returns true if `slug` matches a reserved slug.
// User-view CRUD rejects matches with ErrInvalidInput. Case-folded so
// "Dashboard" is also rejected.
func IsReservedUserViewSlug(slug string) bool {
return slices.Contains(reservedUserViewSlugs, foldSlug(slug))
}
// foldSlug normalises a slug for reserved-list comparison. Slugs are
// already lowercased + dash-only by the validator before this is called,
// but this lets IsReservedUserViewSlug be safe under direct calls.
func foldSlug(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z':
out = append(out, c+('a'-'A'))
default:
out = append(out, c)
}
}
return string(out)
}

View File

@@ -0,0 +1,47 @@
package services
import "testing"
// Pure-Go tests for the SystemView registry. Each system view's specs
// must self-validate; the slugs must be reserved.
func TestSystemViews_AllValidate(t *testing.T) {
for _, sv := range AllSystemViews() {
t.Run(sv.Slug, func(t *testing.T) {
if err := sv.Filter.Validate(); err != nil {
t.Errorf("%s filter spec invalid: %v", sv.Slug, err)
}
if err := sv.Render.Validate(); err != nil {
t.Errorf("%s render spec invalid: %v", sv.Slug, err)
}
})
}
}
func TestSystemViews_SlugsReserved(t *testing.T) {
for _, sv := range AllSystemViews() {
t.Run(sv.Slug, func(t *testing.T) {
if !IsReservedUserViewSlug(sv.Slug) {
t.Errorf("system slug %q must be reserved against user_views", sv.Slug)
}
})
}
}
func TestReservedSlugs_CaseFolded(t *testing.T) {
if !IsReservedUserViewSlug("Dashboard") {
t.Error("reserved-slug check must be case-insensitive")
}
if !IsReservedUserViewSlug("INBOX") {
t.Error("reserved-slug check must be case-insensitive")
}
}
func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
cases := []string{"freitag-stand", "approval-pending-mine", "siemens", "my-view"}
for _, slug := range cases {
if IsReservedUserViewSlug(slug) {
t.Errorf("user-friendly slug %q must not be reserved", slug)
}
}
}

View File

@@ -152,6 +152,76 @@ func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projec
return rows, nil
}
// MembershipEntry is one row in the team-memberships index.
// Powers the /team page project-multi-select filter (t-paliad-147):
// the frontend pulls the index once, then filters users locally
// by intersecting the UI-selected project_ids against each user's
// project_ids list.
type MembershipEntry struct {
UserID uuid.UUID `json:"user_id"`
ProjectIDs []string `json:"project_ids"`
// LeadProjectIDs is the subset of project_ids on which this
// user has role='lead'. Surfaces the "I am a lead on N projects"
// state the broadcast send-button needs.
LeadProjectIDs []string `json:"lead_project_ids"`
// Role on each project — same indexing as project_ids — so the
// frontend can offer a project_teams.role filter.
Roles []string `json:"roles"`
}
// ListMembershipsIndex returns one row per user × project_team membership
// the caller can see. global_admin sees everything; non-admin only sees
// memberships on projects whose visibility predicate they pass.
//
// Membership rows are direct (paliad.project_teams.project_id) only —
// inherited memberships are left to the client to compute, since the
// project-multi-select filter wants "user is on this exact project"
// semantics, not "user inherits from somewhere up the tree".
func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UUID) ([]MembershipEntry, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT pt.user_id::text, pt.project_id::text, pt.role
FROM paliad.project_teams pt
JOIN paliad.projects p ON p.id = pt.project_id
WHERE `+visibilityPredicatePositional("p", 1)+`
ORDER BY pt.user_id, pt.project_id`,
callerID,
)
if err != nil {
return nil, fmt.Errorf("list memberships index: %w", err)
}
defer rows.Close()
byUser := map[uuid.UUID]*MembershipEntry{}
for rows.Next() {
var userIDStr, projectIDStr, role string
if err := rows.Scan(&userIDStr, &projectIDStr, &role); err != nil {
return nil, fmt.Errorf("scan membership: %w", err)
}
uid, err := uuid.Parse(userIDStr)
if err != nil {
continue
}
entry, ok := byUser[uid]
if !ok {
entry = &MembershipEntry{UserID: uid}
byUser[uid] = entry
}
entry.ProjectIDs = append(entry.ProjectIDs, projectIDStr)
entry.Roles = append(entry.Roles, role)
if role == RoleLead {
entry.LeadProjectIDs = append(entry.LeadProjectIDs, projectIDStr)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iter memberships: %w", err)
}
out := make([]MembershipEntry, 0, len(byUser))
for _, e := range byUser {
out = append(out, *e)
}
return out, nil
}
// ---------------------------------------------------------------------------
func isValidRole(r string) bool {

View File

@@ -0,0 +1,377 @@
package services
// UserViewService is the CRUD layer for paliad.user_views — saved Custom
// View definitions per user.
//
// Design: docs/design-data-display-model-2026-05-06.md §5.
//
// Visibility: every read and write is scoped to the calling user via the
// RLS policy `user_views_owner_all` on auth.uid() = user_id. The service
// also AND-joins user_id in the SQL for defense-in-depth (RLS can be
// disabled in tests, the code-level check still holds).
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// UserView is the persisted shape of a saved Custom View.
type UserView struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Slug string `db:"slug" json:"slug"`
Name string `db:"name" json:"name"`
Icon *string `db:"icon" json:"icon,omitempty"`
FilterSpec json.RawMessage `db:"filter_spec" json:"filter_spec"`
RenderSpec json.RawMessage `db:"render_spec" json:"render_spec"`
SortOrder int `db:"sort_order" json:"sort_order"`
ShowCount bool `db:"show_count" json:"show_count"`
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// UserViewService manages paliad.user_views.
type UserViewService struct {
db *sqlx.DB
}
// NewUserViewService wires the service to the pool.
func NewUserViewService(db *sqlx.DB) *UserViewService {
return &UserViewService{db: db}
}
// CreateUserViewInput is the payload for Create.
type CreateUserViewInput struct {
Slug string
Name string
Icon *string
FilterSpec FilterSpec
RenderSpec RenderSpec
ShowCount bool
// SortOrder is server-assigned (MAX+1) on create — callers cannot set it.
}
// UpdateUserViewInput is the partial-update payload. All fields are
// optional; nil means "no change".
type UpdateUserViewInput struct {
Slug *string
Name *string
Icon *string // pointer-to-pointer-of-string would be clearer for "clear vs unchanged"; we treat *string{""} as "clear"
FilterSpec *FilterSpec
RenderSpec *RenderSpec
SortOrder *int
ShowCount *bool
}
// slugRE caps slugs to URL-safe lowercase. Same shape as the migration
// comment promises (^[a-z0-9][a-z0-9-]{0,62}$).
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// ErrUserViewSlugTaken signals "slug already exists for this user". The
// HTTP layer maps this to 409.
var ErrUserViewSlugTaken = errors.New("user_view slug already exists for this user")
// ListForUser returns the caller's saved views, ordered by sort_order ASC
// then name. Result is the same shape /api/user-views returns to the
// frontend on app load (sidebar hydration).
func (s *UserViewService) ListForUser(ctx context.Context, userID uuid.UUID) ([]UserView, error) {
var rows []UserView
err := s.db.SelectContext(ctx, &rows, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1
ORDER BY sort_order ASC, name ASC`, userID)
if err != nil {
return nil, fmt.Errorf("list user_views: %w", err)
}
return rows, nil
}
// GetBySlug fetches one view by slug. Returns (nil, nil) when the slug
// is unknown for this user.
func (s *UserViewService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1 AND slug = $2`, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user_view: %w", err)
}
return &v, nil
}
// GetByID fetches one view by id. Same nil-on-miss semantic.
func (s *UserViewService) GetByID(ctx context.Context, userID, id uuid.UUID) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1 AND id = $2`, userID, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user_view: %w", err)
}
return &v, nil
}
// Create persists a new view for the caller. Server-assigns sort_order
// to MAX(existing)+1 inside the same tx so two parallel creates don't
// collide.
func (s *UserViewService) Create(ctx context.Context, userID uuid.UUID, input CreateUserViewInput) (*UserView, error) {
if err := validateCreateInput(input); err != nil {
return nil, err
}
filterJSON, err := MarshalFilterSpec(input.FilterSpec)
if err != nil {
return nil, err
}
renderJSON, err := MarshalRenderSpec(input.RenderSpec)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var nextSortOrder int
if err := tx.GetContext(ctx, &nextSortOrder, `
SELECT COALESCE(MAX(sort_order), -1) + 1
FROM paliad.user_views
WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("compute next sort_order: %w", err)
}
var v UserView
err = tx.GetContext(ctx, &v, `
INSERT INTO paliad.user_views
(user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at`,
userID, input.Slug, input.Name, input.Icon,
filterJSON, renderJSON, nextSortOrder, input.ShowCount)
if err != nil {
if isUniqueViolation(err) {
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, input.Slug)
}
return nil, fmt.Errorf("insert user_view: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit user_view create: %w", err)
}
return &v, nil
}
// Update applies a partial update to an existing view. Returns
// (nil, nil) if the row doesn't exist for this user.
func (s *UserViewService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateUserViewInput) (*UserView, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
if current == nil {
return nil, nil
}
// Coalesce input over current.
slug := current.Slug
if input.Slug != nil {
slug = *input.Slug
}
if err := validateSlug(slug); err != nil {
return nil, err
}
name := current.Name
if input.Name != nil {
name = *input.Name
}
if err := validateName(name); err != nil {
return nil, err
}
icon := current.Icon
if input.Icon != nil {
s := *input.Icon
if s == "" {
icon = nil
} else {
icon = &s
}
}
filterJSON := []byte(current.FilterSpec)
if input.FilterSpec != nil {
b, err := MarshalFilterSpec(*input.FilterSpec)
if err != nil {
return nil, err
}
filterJSON = b
}
renderJSON := []byte(current.RenderSpec)
if input.RenderSpec != nil {
b, err := MarshalRenderSpec(*input.RenderSpec)
if err != nil {
return nil, err
}
renderJSON = b
}
sortOrder := current.SortOrder
if input.SortOrder != nil {
sortOrder = *input.SortOrder
}
showCount := current.ShowCount
if input.ShowCount != nil {
showCount = *input.ShowCount
}
var v UserView
err = s.db.GetContext(ctx, &v, `
UPDATE paliad.user_views
SET slug = $3, name = $4, icon = $5,
filter_spec = $6, render_spec = $7,
sort_order = $8, show_count = $9,
updated_at = now()
WHERE user_id = $1 AND id = $2
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at`,
userID, id, slug, name, icon, filterJSON, renderJSON, sortOrder, showCount)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
if isUniqueViolation(err) {
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, slug)
}
return nil, fmt.Errorf("update user_view: %w", err)
}
return &v, nil
}
// Delete removes a saved view. Single Yes/No modal on the frontend
// (Q25 lock-in); no audit emit (these are personal working state).
// Returns (false, nil) when the row didn't exist.
func (s *UserViewService) Delete(ctx context.Context, userID, id uuid.UUID) (bool, error) {
res, err := s.db.ExecContext(ctx, `
DELETE FROM paliad.user_views
WHERE user_id = $1 AND id = $2`, userID, id)
if err != nil {
return false, fmt.Errorf("delete user_view: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("delete user_view rows affected: %w", err)
}
return n > 0, nil
}
// Touch updates last_used_at to now. Fire-and-forget from the page
// handler — no error surface to the user.
func (s *UserViewService) Touch(ctx context.Context, userID, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx, `
UPDATE paliad.user_views
SET last_used_at = now()
WHERE user_id = $1 AND id = $2`, userID, id)
if err != nil {
return fmt.Errorf("touch user_view: %w", err)
}
return nil
}
// MostRecent returns the caller's most-recently-used view, or nil if
// the user has none / has never opened one. Used for the /views landing
// (Q10 most-recently-used default).
func (s *UserViewService) MostRecent(ctx context.Context, userID uuid.UUID) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1
AND last_used_at IS NOT NULL
ORDER BY last_used_at DESC
LIMIT 1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("most-recent user_view: %w", err)
}
return &v, nil
}
// ============================================================================
// Validators (slug + name + create input)
// ============================================================================
func validateSlug(slug string) error {
if slug == "" {
return fmt.Errorf("%w: slug is required", ErrInvalidInput)
}
if !slugRE.MatchString(slug) {
return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (got %q)", ErrInvalidInput, slug)
}
if IsReservedUserViewSlug(slug) {
return fmt.Errorf("%w: slug %q is reserved", ErrInvalidInput, slug)
}
return nil
}
func validateName(name string) error {
if name == "" {
return fmt.Errorf("%w: name is required", ErrInvalidInput)
}
// 1-character names are fine (some users may want 1-letter shortcuts).
// 200 is the codebase-wide cap (matches Notes / Checklists).
if len(name) > 200 {
return fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
}
return nil
}
func validateCreateInput(input CreateUserViewInput) error {
if err := validateSlug(input.Slug); err != nil {
return err
}
if err := validateName(input.Name); err != nil {
return err
}
if input.Icon != nil && len(*input.Icon) > 64 {
return fmt.Errorf("%w: icon key exceeds 64 characters", ErrInvalidInput)
}
if err := input.FilterSpec.Validate(); err != nil {
return err
}
if err := input.RenderSpec.Validate(); err != nil {
return err
}
return nil
}
// isUniqueViolation is shared with event_type_service.go (defined there).

View File

@@ -0,0 +1,324 @@
package services
// Live-DB tests for UserViewService. Skipped when TEST_DATABASE_URL is
// unset, mirroring the rest of the live-DB test suite.
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
type userViewTestEnv struct {
t *testing.T
pool *sqlx.DB
svc *UserViewService
userID uuid.UUID
cleanup func()
}
func setupUserViewTest(t *testing.T) *userViewTestEnv {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
ctx := context.Background()
userID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Logf("skip auth.users seed: %v (continuing — auth schema may be locked down)", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'View Test User', 'munich', 'standard')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
cleanup := func() {
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.user_views WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
pool.Close()
}
return &userViewTestEnv{
t: t,
pool: pool,
svc: NewUserViewService(pool),
userID: userID,
cleanup: cleanup,
}
}
func goodCreateInput(slug, name string) CreateUserViewInput {
return CreateUserViewInput{
Slug: slug,
Name: name,
FilterSpec: DefaultFilterSpec(),
RenderSpec: DefaultRenderSpec(),
}
}
func TestUserViewService_CreateAndList(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freitag-stand", "Freitag-Stand"))
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Slug != "freitag-stand" || created.Name != "Freitag-Stand" {
t.Errorf("created shape: %+v", created)
}
if created.SortOrder != 0 {
t.Errorf("first view sort_order = %d, want 0", created.SortOrder)
}
second, err := env.svc.Create(ctx, env.userID, goodCreateInput("siemens", "Siemens-Aktivität"))
if err != nil {
t.Fatalf("second create: %v", err)
}
if second.SortOrder != 1 {
t.Errorf("second view sort_order = %d, want 1", second.SortOrder)
}
list, err := env.svc.ListForUser(ctx, env.userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 views, got %d", len(list))
}
// sort_order ASC ordering
if list[0].Slug != "freitag-stand" || list[1].Slug != "siemens" {
t.Errorf("ordering: %v", []string{list[0].Slug, list[1].Slug})
}
}
func TestUserViewService_GetBySlugAndID(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("test-view", "Test View"))
if err != nil {
t.Fatalf("create: %v", err)
}
bySlug, err := env.svc.GetBySlug(ctx, env.userID, "test-view")
if err != nil || bySlug == nil {
t.Fatalf("GetBySlug: %v / nil", err)
}
if bySlug.ID != created.ID {
t.Errorf("GetBySlug id mismatch")
}
byID, err := env.svc.GetByID(ctx, env.userID, created.ID)
if err != nil || byID == nil {
t.Fatalf("GetByID: %v / nil", err)
}
missing, err := env.svc.GetBySlug(ctx, env.userID, "does-not-exist")
if err != nil {
t.Fatalf("GetBySlug missing: %v", err)
}
if missing != nil {
t.Error("missing slug should return nil")
}
}
func TestUserViewService_SlugUniquenessPerUser(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
if _, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "First")); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "Second"))
if !errors.Is(err, ErrUserViewSlugTaken) {
t.Fatalf("duplicate slug must return ErrUserViewSlugTaken, got %v", err)
}
}
func TestUserViewService_Update(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("orig", "Original"))
if err != nil {
t.Fatalf("create: %v", err)
}
newName := "Updated"
newShowCount := true
updated, err := env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{
Name: &newName,
ShowCount: &newShowCount,
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "Updated" {
t.Errorf("name not updated: %s", updated.Name)
}
if !updated.ShowCount {
t.Errorf("show_count not updated")
}
// Slug should be unchanged.
if updated.Slug != "orig" {
t.Errorf("slug should be unchanged, got %s", updated.Slug)
}
}
func TestUserViewService_UpdateRejectsReservedSlug(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freely", "Freely"))
if err != nil {
t.Fatalf("create: %v", err)
}
reserved := "dashboard"
_, err = env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{Slug: &reserved})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("update to reserved slug must reject, got %v", err)
}
}
func TestUserViewService_Delete(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("doomed", "Doomed"))
if err != nil {
t.Fatalf("create: %v", err)
}
deleted, err := env.svc.Delete(ctx, env.userID, created.ID)
if err != nil || !deleted {
t.Fatalf("delete: %v, %v", deleted, err)
}
deletedAgain, err := env.svc.Delete(ctx, env.userID, created.ID)
if err != nil {
t.Fatalf("second delete: %v", err)
}
if deletedAgain {
t.Error("second delete should report not-deleted")
}
}
func TestUserViewService_TouchAndMostRecent(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
a, _ := env.svc.Create(ctx, env.userID, goodCreateInput("a-view", "A"))
b, _ := env.svc.Create(ctx, env.userID, goodCreateInput("b-view", "B"))
// Before any touch — MostRecent is nil.
mr, err := env.svc.MostRecent(ctx, env.userID)
if err != nil {
t.Fatalf("most_recent: %v", err)
}
if mr != nil {
t.Errorf("MostRecent should be nil before any touch")
}
if err := env.svc.Touch(ctx, env.userID, a.ID); err != nil {
t.Fatalf("touch a: %v", err)
}
if err := env.svc.Touch(ctx, env.userID, b.ID); err != nil {
t.Fatalf("touch b: %v", err)
}
mr, err = env.svc.MostRecent(ctx, env.userID)
if err != nil || mr == nil {
t.Fatalf("most_recent after touch: %v / nil", err)
}
if mr.ID != b.ID {
t.Errorf("most-recent should be b (touched last), got %s", mr.Slug)
}
}
func TestUserViewService_RejectsReservedSlugOnCreate(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("inbox", "Inbox copy"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("reserved slug on create must reject, got %v", err)
}
}
func TestUserViewService_RejectsBadSlug(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("Has Spaces", "Bad"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("slug with spaces must reject, got %v", err)
}
_, err = env.svc.Create(ctx, env.userID, goodCreateInput("UPPER", "Bad"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("uppercase slug must reject, got %v", err)
}
}
func TestUserViewService_RejectsEmptyName(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
Slug: "no-name",
Name: "",
FilterSpec: DefaultFilterSpec(),
RenderSpec: DefaultRenderSpec(),
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty name must reject, got %v", err)
}
}
func TestUserViewService_RejectsInvalidSpec(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
bad := DefaultFilterSpec()
bad.Sources = nil
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
Slug: "bad-spec",
Name: "Bad",
FilterSpec: bad,
RenderSpec: DefaultRenderSpec(),
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("invalid spec must reject, got %v", err)
}
}

View File

@@ -0,0 +1,719 @@
package services
// ViewService extension on EventService — runs a FilterSpec across the
// 4 substrate sources (deadline, appointment, project_event,
// approval_request) and returns a unified []ViewRow.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 + §6.3.
//
// EventService is extended (not renamed) so the existing handlers
// (/api/events, /api/events/summary) keep working unchanged. New
// handlers (/api/views/{slug}/run, /api/user-views/...) call RunSpec.
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// ViewRow is the unified row shape returned by RunSpec. Discriminated by
// `Kind`; type-specific fields live under `Detail` as a per-source struct
// marshalled via json.RawMessage.
type ViewRow struct {
Kind DataSource `json:"kind"`
ID uuid.UUID `json:"id"`
Title string `json:"title"`
// Subtitle: one short context line (e.g. "Frist", "Termin",
// "Genehmigung von …"). Optional; UIs render it under the title.
Subtitle *string `json:"subtitle,omitempty"`
// EventDate is the canonical sort key per row. Source-determined:
// - deadline: due_date at 00:00 UTC
// - appointment: start_at
// - project_event: created_at
// - approval_request: requested_at (or decided_at if status decided)
EventDate time.Time `json:"event_date"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectReference *string `json:"project_reference,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
ActorID *uuid.UUID `json:"actor_id,omitempty"`
ActorName *string `json:"actor_name,omitempty"`
// Detail is the per-source typed payload as raw JSON. Frontend
// type-narrows on Kind and parses Detail accordingly.
Detail json.RawMessage `json:"detail"`
}
// ViewRunResult is the response shape of RunSpec — rows + a count of
// projects that contributed zero rows because the caller can't see them
// (Q17 fail-open attribution).
type ViewRunResult struct {
Rows []ViewRow `json:"rows"`
InaccessibleProjectIDs []uuid.UUID `json:"inaccessible_project_ids,omitempty"`
}
// RunSpec executes the FilterSpec against the substrate and returns
// merged rows sorted by EventDate (ascending for forward-looking,
// descending if any sort hint says so). Visibility is enforced via
// the per-source RLS predicates already used by the underlying tables;
// `userID` is the caller for context propagation.
//
// Caller has run spec.Validate() before us. We trust the spec.
func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService) (*ViewRunResult, error) {
if approval == nil && slices.Contains(spec.Sources, SourceApprovalRequest) {
// Approval source requires the approval service. Return a clear
// error rather than silently skipping it — handlers always pass
// the bundle's approval service.
return nil, fmt.Errorf("RunSpec: approval source selected but ApprovalService is nil")
}
rows := make([]ViewRow, 0, 256)
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
for _, src := range spec.Sources {
switch src {
case SourceDeadline:
batch, err := s.runDeadlines(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceAppointment:
batch, err := s.runAppointments(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceProjectEvent:
batch, err := s.runProjectEvents(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceApprovalRequest:
batch, err := s.runApprovalRequests(ctx, userID, spec, approval, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
}
}
// Default sort: ascending. Per-source sort hints don't apply here —
// Render-side sort (RenderSpec.List/Cards.Sort) is the user-facing
// knob. We give the substrate a stable shape; the renderer flips it.
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].EventDate.Equal(rows[j].EventDate) {
// Tiebreaker: kind alphabetical, then title — deterministic.
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
return rows[i].Title < rows[j].Title
}
return rows[i].EventDate.Before(rows[j].EventDate)
})
out := &ViewRunResult{Rows: rows}
// Q17 fail-open attribution: if the caller specified explicit
// project IDs, surface the ones they couldn't see. We do that with
// one cheap check against can_see_project (via RLS-aware visibility
// predicate), batched per call.
if spec.Scope.Projects.Mode == ScopeExplicit {
inaccessible, err := s.filterInaccessibleProjects(ctx, userID, spec.Scope.Projects.IDs)
if err != nil {
return nil, err
}
if len(inaccessible) > 0 {
out.InaccessibleProjectIDs = inaccessible
}
}
return out, nil
}
// viewSpecBounds carries the resolved [from, to) window the spec
// translates into. Either bound can be nil (open-ended).
type viewSpecBounds struct {
from *time.Time
to *time.Time
}
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
now = now.UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
switch ts.Horizon {
case HorizonNext7d:
from := day
to := day.AddDate(0, 0, 7)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext30d:
from := day
to := day.AddDate(0, 0, 30)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext90d:
from := day
to := day.AddDate(0, 0, 90)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast30d:
from := day.AddDate(0, 0, -30)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast90d:
from := day.AddDate(0, 0, -90)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonAny, HorizonAll:
return viewSpecBounds{}
case HorizonCustom:
return viewSpecBounds{from: ts.From, to: ts.To}
}
return viewSpecBounds{}
}
// runDeadlines projects DeadlineWithProject rows from the existing
// DeadlineService.ListVisibleForUser onto ViewRow, applying spec narrowing.
func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
df := ListFilter{}
if spec.Scope.PersonalOnly {
uid := userID
df.CreatedBy = &uid
}
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
dp := preds.Deadline
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
// If the spec asks for both pending+completed → no narrowing; if
// only pending → DeadlineFilterPending; only completed → Completed.
switch {
case len(dp.Status) == 1 && dp.Status[0] == "pending":
df.Status = DeadlineFilterPending
case len(dp.Status) == 1 && dp.Status[0] == "completed":
df.Status = DeadlineFilterCompleted
default:
df.Status = DeadlineFilterAll
}
df.EventTypeIDs = dp.EventTypeIDs
df.IncludeUntyped = dp.IncludeUntyped
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
// DeadlineService takes one project id; we filter post-load when
// spec selects multiple projects (the visibility predicate already
// bounds to the caller's set, and explicit IDs are a refinement).
}
rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedProjects := explicitProjectSet(spec)
for _, r := range rows {
eventDate := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC)
if !inSpecWindow(eventDate, bounds) {
continue
}
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
continue
}
// Approval-status narrowing (entity-side pill).
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceDeadline) {
continue
}
detail, _ := json.Marshal(map[string]any{
"due_date": r.DueDate.Format("2006-01-02"),
"status": r.Status,
"approval_status": r.ApprovalStatus,
"source": r.Source,
"rule_id": r.RuleID,
"rule_code": r.RuleCode,
"rule_name": r.RuleName,
"event_type_ids": r.EventTypeIDs,
"description": r.Description,
"completed_at": r.CompletedAt,
})
pid := r.ProjectID
pt := r.ProjectTitle
ptype := r.ProjectType
out = append(out, ViewRow{
Kind: SourceDeadline,
ID: r.ID,
Title: r.Title,
EventDate: eventDate,
ProjectID: &pid,
ProjectTitle: &pt,
ProjectReference: r.ProjectReference,
ProjectType: &ptype,
ActorID: r.CreatedBy,
Detail: detail,
})
}
return out, nil
}
// runAppointments projects AppointmentWithProject onto ViewRow.
func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
af := AppointmentListFilter{}
if spec.Scope.PersonalOnly {
uid := userID
af.CreatedBy = &uid
}
af.From = bounds.from
af.To = bounds.to
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
ap := preds.Appointment
// AppointmentListFilter takes a single Type today; narrow to first
// listed value, fall back to all if multiple.
if len(ap.AppointmentTypes) == 1 {
t := ap.AppointmentTypes[0]
af.Type = &t
}
}
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedProjects := explicitProjectSet(spec)
allowedTypes := allowedAppointmentTypes(spec)
for _, r := range rows {
if !inSpecWindow(r.StartAt, bounds) {
continue
}
if r.ProjectID != nil && allowedProjects != nil && !allowedProjects[*r.ProjectID] {
continue
}
if r.ProjectID == nil && allowedProjects != nil {
continue
}
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceAppointment) {
continue
}
if allowedTypes != nil {
if r.AppointmentType == nil || !allowedTypes[*r.AppointmentType] {
continue
}
}
detail, _ := json.Marshal(map[string]any{
"start_at": r.StartAt,
"end_at": r.EndAt,
"location": r.Location,
"appointment_type": r.AppointmentType,
"approval_status": r.ApprovalStatus,
"description": r.Description,
})
out = append(out, ViewRow{
Kind: SourceAppointment,
ID: r.ID,
Title: r.Title,
EventDate: r.StartAt,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
ProjectReference: r.ProjectReference,
ProjectType: r.ProjectType,
ActorID: r.CreatedBy,
Detail: detail,
})
}
return out, nil
}
// runProjectEvents queries paliad.project_events with the visibility
// predicate. The audit table doesn't have a service wrapper today; we
// run our own SQL bounded by the spec.
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
conds := []string{visibilityPredicatePositional("p", 1)}
args := []any{userID}
allowedKinds := allowedProjectEventKinds(spec)
if len(allowedKinds) > 0 {
args = append(args, pq.Array(allowedKinds))
conds = append(conds, fmt.Sprintf("pe.event_type = ANY($%d)", len(args)))
}
if bounds.from != nil {
args = append(args, *bounds.from)
conds = append(conds, fmt.Sprintf("pe.created_at >= $%d", len(args)))
}
if bounds.to != nil {
args = append(args, *bounds.to)
conds = append(conds, fmt.Sprintf("pe.created_at < $%d", len(args)))
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
args = append(args, spec.Scope.Projects.IDs)
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
}
q := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
pe.event_date, pe.created_by, pe.created_at,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference,
u.display_name AS actor_name
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
LEFT JOIN paliad.users u ON u.id = pe.created_by
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY pe.created_at DESC
LIMIT 500`
type row struct {
ID uuid.UUID `db:"id"`
ProjectID uuid.UUID `db:"project_id"`
EventType *string `db:"event_type"`
Title string `db:"title"`
Description *string `db:"description"`
EventDate *time.Time `db:"event_date"`
CreatedBy *uuid.UUID `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
ProjectTitle string `db:"project_title"`
ProjectType string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
ActorName *string `db:"actor_name"`
}
var dbRows []row
if err := s.db.SelectContext(ctx, &dbRows, q, args...); err != nil {
return nil, fmt.Errorf("project_events query: %w", err)
}
out := make([]ViewRow, 0, len(dbRows))
for _, r := range dbRows {
detail, _ := json.Marshal(map[string]any{
"event_type": r.EventType,
"description": r.Description,
"event_date": r.EventDate,
})
pid := r.ProjectID
pt := r.ProjectTitle
ptype := r.ProjectType
out = append(out, ViewRow{
Kind: SourceProjectEvent,
ID: r.ID,
Title: r.Title,
EventDate: r.CreatedAt,
ProjectID: &pid,
ProjectTitle: &pt,
ProjectReference: r.ProjectReference,
ProjectType: &ptype,
ActorID: r.CreatedBy,
ActorName: r.ActorName,
Detail: detail,
})
}
return out, nil
}
// runApprovalRequests projects approval_request rows via the existing
// ApprovalService inbox queries. ViewerRole picks which underlying
// query runs.
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
preds := spec.Predicates[SourceApprovalRequest]
role := "approver_eligible"
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
role = preds.ApprovalRequest.ViewerRole
}
filter := InboxFilter{}
if preds.ApprovalRequest != nil {
// InboxFilter takes a single status today. If the spec says
// only one, narrow; if multiple, leave open.
if len(preds.ApprovalRequest.Status) == 1 {
filter.Status = preds.ApprovalRequest.Status[0]
}
if len(preds.ApprovalRequest.EntityTypes) == 1 {
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
}
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
pid := spec.Scope.Projects.IDs[0]
filter.ProjectID = &pid
}
var rows []ApprovalRequestView
var err error
switch role {
case "approver_eligible":
rows, err = approval.ListPendingForApprover(ctx, userID, filter)
case "self_requested":
rows, err = approval.ListSubmittedByUser(ctx, userID, filter)
case "any_visible":
// any_visible is the broadest read — RLS bounds it. The existing
// ApprovalService doesn't have a "list all visible" call; we
// approximate by running both inbox queries and de-duping. Future
// optimization: dedicated service method.
a, errA := approval.ListPendingForApprover(ctx, userID, filter)
if errA != nil {
return nil, errA
}
b, errB := approval.ListSubmittedByUser(ctx, userID, filter)
if errB != nil {
return nil, errB
}
seen := make(map[uuid.UUID]bool, len(a)+len(b))
for _, r := range a {
if !seen[r.ID] {
rows = append(rows, r)
seen[r.ID] = true
}
}
for _, r := range b {
if !seen[r.ID] {
rows = append(rows, r)
seen[r.ID] = true
}
}
default:
return nil, fmt.Errorf("%w: approval_request.viewer_role %q", ErrInvalidInput, role)
}
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedStatuses := allowedRequestStatuses(spec)
allowedEntityTypes := allowedRequestEntityTypes(spec)
allowedProjects := explicitProjectSet(spec)
for _, r := range rows {
// Spec status filter (when the inbox query received broad results).
if allowedStatuses != nil && !allowedStatuses[r.Status] {
continue
}
if allowedEntityTypes != nil && !allowedEntityTypes[r.EntityType] {
continue
}
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
continue
}
// Sort key: decided_at if decided, else requested_at.
eventDate := r.RequestedAt
if r.DecidedAt != nil {
eventDate = *r.DecidedAt
}
if !inSpecWindow(eventDate, bounds) {
continue
}
title := approvalRowTitle(r)
subtitle := approvalRowSubtitle(r)
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
actorID := r.RequestedBy
actorName := r.RequesterName
pid := r.ProjectID
pt := r.ProjectTitle
out = append(out, ViewRow{
Kind: SourceApprovalRequest,
ID: r.ID,
Title: title,
Subtitle: &subtitle,
EventDate: eventDate,
ProjectID: &pid,
ProjectTitle: &pt,
ActorID: &actorID,
ActorName: &actorName,
Detail: detail,
})
}
return out, nil
}
// approvalRowTitle returns a one-line title describing the approval
// request — used as the ViewRow.Title.
func approvalRowTitle(r ApprovalRequestView) string {
if r.EntityTitle != nil && *r.EntityTitle != "" {
return *r.EntityTitle
}
return fmt.Sprintf("%s %s", r.EntityType, r.LifecycleEvent)
}
// approvalRowSubtitle returns a one-line context for the request.
func approvalRowSubtitle(r ApprovalRequestView) string {
switch r.Status {
case "pending":
return fmt.Sprintf("Genehmigung angefragt von %s", r.RequesterName)
case "approved":
if r.DeciderName != nil {
return fmt.Sprintf("Genehmigt von %s", *r.DeciderName)
}
return "Genehmigt"
case "rejected":
if r.DeciderName != nil {
return fmt.Sprintf("Abgelehnt von %s", *r.DeciderName)
}
return "Abgelehnt"
case "revoked":
return "Widerrufen"
}
return r.Status
}
// inSpecWindow returns true when ts is within [from, to). nil bounds
// are open-ended.
func inSpecWindow(ts time.Time, b viewSpecBounds) bool {
if b.from != nil && ts.Before(*b.from) {
return false
}
if b.to != nil && !ts.Before(*b.to) {
return false
}
return true
}
// explicitProjectSet returns nil when the scope isn't explicit, otherwise
// a set membership map for fast filtering.
func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
if spec.Scope.Projects.Mode != ScopeExplicit {
return nil
}
out := make(map[uuid.UUID]bool, len(spec.Scope.Projects.IDs))
for _, id := range spec.Scope.Projects.IDs {
out[id] = true
}
return out
}
// approvalStatusMatches checks the entity-side approval_status filter.
// Returns true when the row passes (no filter set → always true).
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
preds, ok := spec.Predicates[src]
if !ok {
return true
}
var allowed []string
switch src {
case SourceDeadline:
if preds.Deadline != nil {
allowed = preds.Deadline.ApprovalStatus
}
case SourceAppointment:
if preds.Appointment != nil {
allowed = preds.Appointment.ApprovalStatus
}
}
if len(allowed) == 0 {
return true
}
return slices.Contains(allowed, rowStatus)
}
// allowedAppointmentTypes returns nil when the filter is open, otherwise
// a set of legal appointment_type values.
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceAppointment]
if !ok || preds.Appointment == nil {
return nil
}
if len(preds.Appointment.AppointmentTypes) <= 1 {
return nil // single-value already pushed down via AppointmentListFilter.Type
}
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
for _, t := range preds.Appointment.AppointmentTypes {
out[t] = true
}
return out
}
// allowedProjectEventKinds returns the slice of project_event.event_type
// values the spec narrows to, or nil for "all known kinds".
func allowedProjectEventKinds(spec FilterSpec) []string {
preds, ok := spec.Predicates[SourceProjectEvent]
if !ok || preds.ProjectEvent == nil {
return nil
}
if len(preds.ProjectEvent.EventTypes) == 0 {
return nil
}
return preds.ProjectEvent.EventTypes
}
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
// already pushed into InboxFilter.Status").
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.Status) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
for _, s := range preds.ApprovalRequest.Status {
out[s] = true
}
return out
}
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
for _, t := range preds.ApprovalRequest.EntityTypes {
out[t] = true
}
return out
}
// filterInaccessibleProjects returns the subset of `requested` that the
// caller cannot see. Implementation: SELECT id FROM paliad.projects
// WHERE id = ANY(...) (RLS filters the visible ones); the missing ones
// are inaccessible. One DB hit per RunSpec when scope is explicit.
func (s *EventService) filterInaccessibleProjects(ctx context.Context, userID uuid.UUID, requested []uuid.UUID) ([]uuid.UUID, error) {
if len(requested) == 0 {
return nil, nil
}
q := `SELECT p.id
FROM paliad.projects p
WHERE p.id = ANY($1)
AND ` + visibilityPredicatePositional("p", 2)
var visible []uuid.UUID
if err := s.db.SelectContext(ctx, &visible, q, requested, userID); err != nil {
return nil, fmt.Errorf("filter inaccessible projects: %w", err)
}
visibleSet := make(map[uuid.UUID]bool, len(visible))
for _, id := range visible {
visibleSet[id] = true
}
out := make([]uuid.UUID, 0)
for _, id := range requested {
if !visibleSet[id] {
out = append(out, id)
}
}
return out, nil
}
// Compile-time guards: the substrate's source loaders read fields off
// known model shapes. If a model rename breaks this, the build fails
// here rather than at runtime in production.
var (
_ = models.DeadlineWithProject{}
_ = models.AppointmentWithProject{}
_ = models.ProjectEvent{}
)