refactor: services — Projekt, Team, Dezernat services (WIP Phase 2)

Models: Akte → Projekt (tree type + parent_id + path + client/matter numbers
+ netDocuments URL + type-specific client/patent/case columns). AkteEvent →
ProjektEvent. FristWithAkte → FristWithProjekt. TerminWithAkte → TerminWithProjekt.
Notiz.AkteID → ProjektID. ChecklistInstance.AkteID → ProjektID. Partei.AkteID →
ProjektID. User adds AdditionalOffices pq.StringArray.

Services:
- NEW projekt_service.go replaces akte_service.go. Adds tree ops: List/GetByID/
  ListChildren/ListAncestors/GetTree. Create auto-adds creator to projekt_teams
  role=lead in same tx. ResolveClientNumber walks path for inheritance.
  Visibility helpers (visibilityPredicate / Positional / Placeholder) centralise
  team-based access check: admin OR any ancestor/direct projekt_teams row.
- NEW team_service.go — AddMember/RemoveMember/ListDirectMembers/
  ListEffectiveMembers (unions direct + inherited via path, dedup by user;
  direct wins)/IsEffectiveMember. Inherited=true set at read time only.
- NEW dezernat_service.go — admin-gated CRUD + member add/remove + user
  membership lookup for settings page.
- frist_service.go → projekt_id everywhere, uses visibilityPredicate. ListFilter.
  AkteID → ProjektID.
- termin_service.go → projekt_id everywhere. CalDAV log reads projekt_events.
- notiz_service.go → projekt_id polymorphic branch; eventProjektID() looks at
  projekt_events; akten_event_id column kept (FK now resolves to projekt_events).
- parteien_service.go → projekt_id.
- checklist_instance_service.go → projekt_id with ClearProjekt flag.
- dashboard_service.go → rewrites all four queries against projekte +
  projekt_events + projekt_teams. Matter/Upcoming/Activity surfaces use
  ProjektID/ProjektTitle/ProjektRef.
- reminder_service.go → joins paliad.projekte, aliases a.reference AS
  akte_aktenzeichen for template compat.

Handlers/tests still reference old API — Phase 2 completion requires handler
rewrite (next commit). Build currently broken in internal/handlers.
This commit is contained in:
m
2026-04-20 14:46:59 +02:00
parent 5fcaa7471b
commit 9aa8037193
12 changed files with 1693 additions and 1050 deletions

View File

@@ -1,5 +1,5 @@
// Package models holds the database row types for paliad.* tables.
// Names mirror the German schema (Akte, Frist, Termin, Notiz, …).
// Names mirror the German schema (Projekt, Frist, Termin, Notiz, …).
// See internal/db/migrations/ for the canonical schema definitions.
package models
@@ -12,53 +12,123 @@ import (
)
// User extends auth.users with firm-specific profile fields. Created by the
// Phase D onboarding flow; without a row here, the user can't see any Akten.
// Phase D onboarding flow; without a row here, the user can't see any Projekte.
type User struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
DisplayName string `db:"display_name" json:"display_name"`
Office string `db:"office" json:"office"`
PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"`
Role string `db:"role" json:"role"`
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
// Lang is the preferred UI language for transactional email ("de"/"en").
// NOT NULL (migration 017) with DB default 'de'; the settings page lets
// every user flip it.
Lang string `db:"lang" json:"lang"`
// EmailPreferences is an opaque JSONB bag. Well-known keys today:
// deadline_reminders (bool, default true if missing)
// deadline_reminders.overdue (bool, default true)
// deadline_reminders.tomorrow (bool, default true)
// deadline_reminders.weekly (bool, default true)
// Missing key = opt-in, matching the pre-settings-page default.
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// AdditionalOffices lists secondary offices a partner works across.
// Informational only — office is not a visibility gate under the v2
// data model (t-paliad-024).
AdditionalOffices pq.StringArray `db:"additional_offices" json:"additional_offices"`
PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"`
Role string `db:"role" json:"role"`
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
Lang string `db:"lang" json:"lang"`
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Akte is a matter (case file). Office-scoped visibility: see paliad.can_see_akte.
type Akte struct {
ID uuid.UUID `db:"id" json:"id"`
Aktenzeichen string `db:"aktenzeichen" json:"aktenzeichen"`
Title string `db:"title" json:"title"`
AkteType *string `db:"akte_type" json:"akte_type,omitempty"`
Court *string `db:"court" json:"court,omitempty"`
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
Status string `db:"status" json:"status"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
OwningOffice string `db:"owning_office" json:"owning_office"`
Collaborators pq.StringArray `db:"collaborators" json:"collaborators"`
FirmWideVisible bool `db:"firm_wide_visible" json:"firm_wide_visible"`
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"`
// Projekt is one node in the paliad.projekte tree. Visibility is team-based
// (direct or inherited via the materialised path) — see paliad.can_see_projekt.
// Type-specific fields are nullable; the service layer enforces the subset
// that applies to each type.
type Projekt struct {
ID uuid.UUID `db:"id" json:"id"`
Type string `db:"type" json:"type"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
// Path is the '.'-joined UUID list from root to self (inclusive).
// Maintained by a Postgres trigger — writes from the service are ignored.
Path string `db:"path" json:"path"`
Title string `db:"title" json:"title"`
Reference *string `db:"reference" json:"reference,omitempty"`
Description *string `db:"description" json:"description,omitempty"`
Status string `db:"status" json:"status"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
// Client-specific (type='client'), nullable otherwise.
Industry *string `db:"industry" json:"industry,omitempty"`
Country *string `db:"country" json:"country,omitempty"`
BillingReference *string `db:"billing_reference" json:"billing_reference,omitempty"`
// ClientMatter numbers — external HLC billing/DMS identifiers.
// Child rows inherit client_number from the root by default (resolved at
// read time by the service); a child with its own client_number overrides.
// matter_number is assigned independently at any level.
ClientNumber *string `db:"client_number" json:"client_number,omitempty"`
MatterNumber *string `db:"matter_number" json:"matter_number,omitempty"`
NetDocumentsURL *string `db:"netdocuments_url" json:"netdocuments_url,omitempty"`
// Patent-specific (type='patent').
PatentNumber *string `db:"patent_number" json:"patent_number,omitempty"`
FilingDate *time.Time `db:"filing_date" json:"filing_date,omitempty"`
GrantDate *time.Time `db:"grant_date" json:"grant_date,omitempty"`
// Case-specific (type='case').
Court *string `db:"court" json:"court,omitempty"`
CaseNumber *string `db:"case_number" json:"case_number,omitempty"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// AkteEvent is one row in the per-Akte audit trail.
type AkteEvent struct {
// ProjektTeamMember is one row of paliad.projekt_teams — direct membership
// only. Inherited memberships are computed at read time by walking the path;
// services set Inherited=true on the in-memory copy when annotating a list
// result that mixes direct + inherited rows.
type ProjektTeamMember struct {
ID uuid.UUID `db:"id" json:"id"`
ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Role string `db:"role" json:"role"`
Inherited bool `db:"inherited" json:"inherited"`
AddedBy *uuid.UUID `db:"added_by" json:"added_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ProjektTeamMemberWithUser enriches a team row with display fields so the
// UI can render "<DisplayName> (<Email>) — <Role>" without a per-row lookup.
// Used by TeamService.ListMembers which unions direct + inherited memberships.
type ProjektTeamMemberWithUser struct {
ProjektTeamMember
UserEmail string `db:"user_email" json:"user_email"`
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
UserOffice string `db:"user_office" json:"user_office"`
// InheritedFromID is the ancestor projekt_id the membership came from
// when Inherited=true. NULL for direct rows.
InheritedFromID *uuid.UUID `db:"inherited_from_id" json:"inherited_from_id,omitempty"`
InheritedFromTitle *string `db:"inherited_from_title" json:"inherited_from_title,omitempty"`
}
// Dezernat is one structural partner unit. Dezernat membership is orthogonal
// to project teams — a user typically belongs to exactly one Dezernat but
// may work on projects across all of them.
type Dezernat struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
LeadUserID *uuid.UUID `db:"lead_user_id" json:"lead_user_id,omitempty"`
Office string `db:"office" json:"office"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// DezernatMitglied is one user's membership in a Dezernat.
type DezernatMitglied struct {
DezernatID uuid.UUID `db:"dezernat_id" json:"dezernat_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ProjektEvent is one row in the per-Projekt audit trail (paliad.projekt_events,
// renamed from paliad.akten_events in migration 018).
type ProjektEvent struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
@@ -69,11 +139,12 @@ type AkteEvent struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Frist is one persistent deadline attached to an Akte.
// Visibility is inherited from the parent Akte (see paliad.can_see_akte).
// Frist is one persistent deadline attached to a Projekt (typically a case-
// or patent-level node). Visibility is inherited from the parent Projekt via
// paliad.can_see_projekt.
type Frist struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
DueDate time.Time `db:"due_date" json:"due_date"`
@@ -91,23 +162,21 @@ type Frist struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// FristWithAkte enriches a Frist with parent Akte fields needed by the
// /fristen list page (Akten ref + title + office) without requiring a
// per-row /api/akten/{id} fetch.
type FristWithAkte struct {
// FristWithProjekt enriches a Frist with parent-Projekt display fields
// (reference + title) for list views.
type FristWithProjekt struct {
Frist
AkteAktenzeichen string `db:"akte_aktenzeichen" json:"akte_aktenzeichen"`
AkteTitle string `db:"akte_title" json:"akte_title"`
AkteOffice string `db:"akte_office" json:"akte_office"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"`
ProjektTitle string `db:"projekt_title" json:"projekt_title"`
ProjektType string `db:"projekt_type" json:"projekt_type"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
}
// Termin is one appointment. akte_id is nullable: when NULL the Termin is
// personal (visible only to the creator); when set it follows the parent
// Akte's office-scoped visibility.
// Termin is one appointment. projekt_id is nullable: NULL = personal
// (creator-only); set = follows the parent Projekt's team visibility.
type Termin struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID *uuid.UUID `db:"akte_id" json:"akte_id,omitempty"`
ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
StartAt time.Time `db:"start_at" json:"start_at"`
@@ -121,70 +190,65 @@ type Termin struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// TerminWithAkte enriches a Termin with parent Akte fields for the list
// view, mirroring FristWithAkte. Akte fields are nullable because personal
// Termine have no parent Akte.
type TerminWithAkte struct {
// TerminWithProjekt enriches a Termin with its parent Projekt display
// fields for list views. All fields nullable because personal Termine have
// no parent.
type TerminWithProjekt struct {
Termin
AkteAktenzeichen *string `db:"akte_aktenzeichen" json:"akte_aktenzeichen,omitempty"`
AkteTitle *string `db:"akte_title" json:"akte_title,omitempty"`
AkteOffice *string `db:"akte_office" json:"akte_office,omitempty"`
ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"`
ProjektTitle *string `db:"projekt_title" json:"projekt_title,omitempty"`
ProjektType *string `db:"projekt_type" json:"projekt_type,omitempty"`
}
// Notiz is one polymorphic note attached to exactly one parent row
// (Akte, Frist, Termin, or AktenEvent). Visibility is inherited from
// whichever parent FK is set — see paliad.notiz_is_visible.
// (Projekt, Frist, Termin, or ProjektEvent). Visibility follows the parent.
type Notiz struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID *uuid.UUID `db:"akte_id" json:"akte_id,omitempty"`
ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"`
FristID *uuid.UUID `db:"frist_id" json:"frist_id,omitempty"`
TerminID *uuid.UUID `db:"termin_id" json:"termin_id,omitempty"`
// AktenEventID column name was kept for continuity with the v1 schema;
// the FK now resolves to paliad.projekt_events (renamed in 018).
AktenEventID *uuid.UUID `db:"akten_event_id" json:"akten_event_id,omitempty"`
Content string `db:"content" json:"content"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// Author display fields, populated by the service's LEFT JOIN to
// paliad.users so the client can render "von <Name>" without a
// per-row /api/users lookup.
// Author display fields populated by the service's LEFT JOIN to
// paliad.users so the UI can render "von <Name>" without a lookup.
AuthorName *string `db:"author_name" json:"author_name,omitempty"`
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
}
// ChecklistInstance is one user's instantiation of a static checklist
// template (defined in internal/checklisten). Checkbox state lives in
// the `state` jsonb column as {"g{groupIdx}-i{itemIdx}": true}.
// template (defined in internal/checklisten). Checkbox state lives in the
// `state` jsonb column.
//
// Visibility mirrors Termin: akte_id nullable. Personal instances
// (akte_id NULL) are creator-only; Akte-linked instances follow the
// office-scoped paliad.can_see_akte gate.
// Visibility mirrors Termin: projekt_id nullable. Personal instances
// (projekt_id NULL) are creator-only; Projekt-linked instances follow
// paliad.can_see_projekt.
type ChecklistInstance struct {
ID uuid.UUID `db:"id" json:"id"`
TemplateSlug string `db:"template_slug" json:"template_slug"`
Name string `db:"name" json:"name"`
AkteID *uuid.UUID `db:"akte_id" json:"akte_id,omitempty"`
ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"`
State json.RawMessage `db:"state" json:"state"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ChecklistInstanceWithAkte enriches an instance with its parent Akte
// reference fields for list views (so the template page can show
// "Case A — Aktenzeichen 2026/0042" without a per-row lookup).
type ChecklistInstanceWithAkte struct {
// ChecklistInstanceWithProjekt enriches an instance with its parent Projekt
// reference fields for list views.
type ChecklistInstanceWithProjekt struct {
ChecklistInstance
AkteAktenzeichen *string `db:"akte_aktenzeichen" json:"akte_aktenzeichen,omitempty"`
AkteTitle *string `db:"akte_title" json:"akte_title,omitempty"`
ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"`
ProjektTitle *string `db:"projekt_title" json:"projekt_title,omitempty"`
}
// UserCalDAVConfig holds one user's external CalDAV connection. The
// password is never returned in API responses; only the public fields
// (URL, username, calendar path, enabled, last_sync_at) are exposed.
//
// password_encrypted is AES-GCM ciphertext with a 12-byte random nonce
// prepended. Decrypted only inside services.CalDAVService.
// UserCalDAVConfig holds one user's external CalDAV connection. The password
// is never returned in API responses; only the public fields are exposed.
type UserCalDAVConfig struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
URL string `db:"url" json:"url"`
@@ -198,7 +262,7 @@ type UserCalDAVConfig struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// CalDAVSyncLogEntry is one historical sync record (last 5 retained per user).
// CalDAVSyncLogEntry is one historical sync record.
type CalDAVSyncLogEntry struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
@@ -210,10 +274,11 @@ type CalDAVSyncLogEntry struct {
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
}
// Partei is a party to an Akte (Kläger, Beklagter, etc.).
// Partei is a party to a Projekt (Kläger, Beklagter, etc. — typically on
// a case-level projekt).
type Partei struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"`
Name string `db:"name" json:"name"`
Role *string `db:"role" json:"role,omitempty"`
Representative *string `db:"representative" json:"representative,omitempty"`

View File

@@ -1,432 +0,0 @@
package services
// AkteService handles CRUD on paliad.akten with office-scoped visibility.
//
// Auth model (design §2):
//
// A user can see an Akte iff
// Akte.firm_wide_visible
// OR Akte.owning_office = user.office
// OR user.id ∈ Akte.collaborators
// OR user.role = 'admin'
//
// The canonical predicate lives in SQL (paliad.can_see_akte, migration 006)
// and is enforced by RLS policies on every table. This service re-implements
// the same predicate at the application layer so queries are efficient
// (indexed joins) and so the check happens even for the service-role
// connection (Supabase RLS only kicks in when the connection provides a
// JWT-backed auth.uid(), which the Paliad backend does not).
//
// Belt-and-braces posture:
// - Production writes go through the service, which enforces visibility.
// - When Phase D lands direct PostgREST access for clients, RLS picks up
// the slack via Supabase's JWT-to-auth.uid() injection.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/models"
"mgit.msbls.de/m/patholo/internal/offices"
)
// Sentinel errors returned by AkteService.
var (
// ErrNotVisible indicates the Akte exists but the user has no visibility.
// Handlers should map this to 404 (never leak existence).
ErrNotVisible = errors.New("akte not visible")
// ErrForbidden indicates the user is authenticated but lacks the role
// required for the operation (e.g., associate trying to delete).
ErrForbidden = errors.New("forbidden")
// ErrInvalidInput signals a bad request (empty required field, invalid
// office, etc.). Handlers should map to 400.
ErrInvalidInput = errors.New("invalid input")
)
// AkteService reads and writes paliad.akten + paliad.akten_events.
type AkteService struct {
db *sqlx.DB
users *UserService
}
// NewAkteService wires the service to its dependencies.
func NewAkteService(db *sqlx.DB, users *UserService) *AkteService {
return &AkteService{db: db, users: users}
}
const akteColumns = `id, aktenzeichen, title, akte_type, court, court_ref, status,
ai_summary, owning_office, collaborators, firm_wide_visible,
created_by, metadata, created_at, updated_at`
// CreateAkteInput is the payload for Create.
type CreateAkteInput struct {
Aktenzeichen string `json:"aktenzeichen"`
Title string `json:"title"`
AkteType *string `json:"akte_type,omitempty"`
Court *string `json:"court,omitempty"`
CourtRef *string `json:"court_ref,omitempty"`
Status string `json:"status,omitempty"` // default "active"
OwningOffice string `json:"owning_office,omitempty"` // default to user's office
}
// UpdateAkteInput is the partial-update payload for Update.
type UpdateAkteInput struct {
Aktenzeichen *string `json:"aktenzeichen,omitempty"`
Title *string `json:"title,omitempty"`
AkteType *string `json:"akte_type,omitempty"`
Court *string `json:"court,omitempty"`
CourtRef *string `json:"court_ref,omitempty"`
Status *string `json:"status,omitempty"`
OwningOffice *string `json:"owning_office,omitempty"` // admin-only
Collaborators []string `json:"collaborators,omitempty"` // full-replace
FirmWideVisible *bool `json:"firm_wide_visible,omitempty"` // partner/admin only
}
// ListVisibleForUser returns all Akten visible to the user, newest first.
// Uses paliad.can_see_akte() on the server side for correctness, and filters
// by the visibility predicate manually as a backup (defense in depth).
func (s *AkteService) ListVisibleForUser(ctx context.Context, userID uuid.UUID) ([]models.Akte, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
// No paliad.users row → no visibility (safe default).
return []models.Akte{}, nil
}
var akten []models.Akte
// Conditions express the visibility predicate directly so indexes on
// owning_office + collaborators GIN can do the work.
query := `SELECT ` + akteColumns + `
FROM paliad.akten
WHERE firm_wide_visible = true
OR owning_office = $1
OR $2 = ANY (collaborators)
OR $3 = 'admin'
ORDER BY updated_at DESC`
if err := s.db.SelectContext(ctx, &akten, query, user.Office, userID, user.Role); err != nil {
return nil, fmt.Errorf("list visible akten: %w", err)
}
return akten, nil
}
// GetByID returns the Akte if the user can see it. Returns (nil, ErrNotVisible)
// if the Akte doesn't exist OR the user lacks visibility — handlers must not
// distinguish the two in their response.
func (s *AkteService) GetByID(ctx context.Context, userID, akteID uuid.UUID) (*models.Akte, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrNotVisible
}
var a models.Akte
query := `SELECT ` + akteColumns + `
FROM paliad.akten
WHERE id = $1
AND (firm_wide_visible = true
OR owning_office = $2
OR $3 = ANY (collaborators)
OR $4 = 'admin')`
err = s.db.GetContext(ctx, &a, query, akteID, user.Office, userID, user.Role)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("get akte: %w", err)
}
return &a, nil
}
// Create inserts a new Akte. Non-admins may only create in their own office.
func (s *AkteService) Create(ctx context.Context, userID uuid.UUID, input CreateAkteInput) (*models.Akte, error) {
if strings.TrimSpace(input.Aktenzeichen) == "" || strings.TrimSpace(input.Title) == "" {
return nil, fmt.Errorf("%w: aktenzeichen and title are required", ErrInvalidInput)
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
}
office := input.OwningOffice
if office == "" {
office = user.Office
}
if office != user.Office && user.Role != "admin" {
return nil, fmt.Errorf("%w: cannot create Akte in office %q (not your office, not admin)", ErrForbidden, office)
}
if !offices.IsValid(office) {
return nil, fmt.Errorf("%w: invalid owning_office %q", ErrInvalidInput, office)
}
status := input.Status
if status == "" {
status = "active"
}
id := uuid.New()
now := time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.akten
(id, aktenzeichen, title, akte_type, court, court_ref, status,
owning_office, collaborators, firm_wide_visible, created_by,
metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', false, $9, '{}', $10, $10)`,
id, input.Aktenzeichen, input.Title, input.AkteType, input.Court, input.CourtRef,
status, office, userID, now,
); err != nil {
return nil, fmt.Errorf("insert akte: %w", err)
}
if err := insertAkteEvent(ctx, tx, id, userID, "akte_created", "Akte created", nil); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create akte: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// Update applies a partial update. Visibility checked first; collaborator +
// firm_wide toggles require the appropriate role.
func (s *AkteService) Update(ctx context.Context, userID, akteID uuid.UUID, input UpdateAkteInput) (*models.Akte, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrNotVisible
}
current, err := s.GetByID(ctx, userID, akteID)
if err != nil {
return nil, err
}
// Role-gated fields
if input.FirmWideVisible != nil && user.Role != "partner" && user.Role != "admin" {
return nil, fmt.Errorf("%w: only partners/admins can toggle firm_wide_visible", ErrForbidden)
}
if input.OwningOffice != nil && user.Role != "admin" {
return nil, fmt.Errorf("%w: only admins can move an Akte between offices", ErrForbidden)
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Aktenzeichen != nil {
appendSet("aktenzeichen", *input.Aktenzeichen)
}
if input.Title != nil {
appendSet("title", *input.Title)
}
if input.AkteType != nil {
appendSet("akte_type", *input.AkteType)
}
if input.Court != nil {
appendSet("court", *input.Court)
}
if input.CourtRef != nil {
appendSet("court_ref", *input.CourtRef)
}
if input.Status != nil {
appendSet("status", *input.Status)
}
if input.OwningOffice != nil {
if !offices.IsValid(*input.OwningOffice) {
return nil, fmt.Errorf("%w: invalid owning_office %q", ErrInvalidInput, *input.OwningOffice)
}
appendSet("owning_office", *input.OwningOffice)
}
if input.Collaborators != nil {
appendSet("collaborators", pq.Array(input.Collaborators))
}
if input.FirmWideVisible != nil {
appendSet("firm_wide_visible", *input.FirmWideVisible)
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, akteID)
query := fmt.Sprintf(
"UPDATE paliad.akten SET %s WHERE id = $%d",
strings.Join(sets, ", "), next,
)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update akte: %w", err)
}
// Event for noteworthy changes
if input.Status != nil && *input.Status != current.Status {
desc := fmt.Sprintf("Status changed from %s to %s", current.Status, *input.Status)
if err := insertAkteEvent(ctx, tx, akteID, userID, "status_changed", desc, nil); err != nil {
return nil, err
}
}
if input.FirmWideVisible != nil && *input.FirmWideVisible != current.FirmWideVisible {
title := "Firm-wide visibility enabled"
if !*input.FirmWideVisible {
title = "Firm-wide visibility disabled"
}
if err := insertAkteEvent(ctx, tx, akteID, userID, "visibility_changed", title, nil); err != nil {
return nil, err
}
}
if input.Collaborators != nil {
if err := insertAkteEvent(ctx, tx, akteID, userID, "collaborators_updated", "Collaborators updated", nil); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update akte: %w", err)
}
return s.GetByID(ctx, userID, akteID)
}
// Delete archives the Akte (soft-delete, status='archived'). Partners + admins only.
func (s *AkteService) Delete(ctx context.Context, userID, akteID uuid.UUID) error {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
return fmt.Errorf("%w: only partners/admins can delete Akten", ErrForbidden)
}
if _, err := s.GetByID(ctx, userID, akteID); err != nil {
return err // ErrNotVisible propagates
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE paliad.akten
SET status = 'archived', updated_at = $1
WHERE id = $2 AND status != 'archived'`,
time.Now().UTC(), akteID)
if err != nil {
return fmt.Errorf("archive akte: %w", err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
// Already archived — no-op, still commit the empty tx cleanly.
return tx.Commit()
}
if err := insertAkteEvent(ctx, tx, akteID, userID, "akte_archived", "Akte archived", nil); err != nil {
return err
}
return tx.Commit()
}
// MaxEventsPageLimit caps ListEvents page size so a malicious client cannot
// request unbounded result sets.
const MaxEventsPageLimit = 200
// DefaultEventsPageLimit is the page size used when the caller omits ?limit=.
const DefaultEventsPageLimit = 50
// ListEvents returns the audit trail for the Akte, newest first, with cursor
// pagination. When before is nil the query starts at the most recent event;
// otherwise it returns events strictly older than the referenced event
// (composite (created_at, id) cursor so rows created in the same microsecond
// don't get duplicated or skipped across pages).
//
// limit is clamped to MaxEventsPageLimit. limit <= 0 falls back to
// DefaultEventsPageLimit.
//
// Visibility is enforced through GetByID on the parent.
func (s *AkteService) ListEvents(ctx context.Context, userID, akteID uuid.UUID, before *uuid.UUID, limit int) ([]models.AkteEvent, error) {
if _, err := s.GetByID(ctx, userID, akteID); err != nil {
return nil, err
}
if limit <= 0 {
limit = DefaultEventsPageLimit
}
if limit > MaxEventsPageLimit {
limit = MaxEventsPageLimit
}
var beforeArg any
if before != nil {
beforeArg = *before
}
var events []models.AkteEvent
err := s.db.SelectContext(ctx, &events,
`SELECT id, akte_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at
FROM paliad.akten_events
WHERE akte_id = $1
AND ($2::uuid IS NULL OR (created_at, id) < (
SELECT created_at, id FROM paliad.akten_events WHERE id = $2::uuid
))
ORDER BY created_at DESC, id DESC
LIMIT $3`, akteID, beforeArg, limit)
if err != nil {
return nil, fmt.Errorf("list akte events: %w", err)
}
return events, nil
}
// insertAkteEvent appends one row to paliad.akten_events inside the given tx.
func insertAkteEvent(ctx context.Context, tx *sqlx.Tx, akteID, userID uuid.UUID, eventType, title string, description *string) error {
now := time.Now().UTC()
meta := json.RawMessage(`{}`)
_, err := tx.ExecContext(ctx,
`INSERT INTO paliad.akten_events
(id, akte_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $6, $6)`,
uuid.New(), akteID, eventType, title, description, now, userID, meta)
if err != nil {
return fmt.Errorf("insert akte_event: %w", err)
}
return nil
}

View File

@@ -18,99 +18,77 @@ import (
// ChecklistInstanceService reads and writes paliad.checklist_instances.
//
// An "instance" is a user's working copy of a static checklist template
// (UPC Klageschrift, EPA Einspruch, …). Template data lives in
// internal/checklisten; per-instance checkbox state lives here in the
// `state` jsonb column.
//
// Visibility mirrors paliad.termine (akte_id nullable):
//
// - akte_id NULL → creator-only (personal instance)
// - akte_id NOT NULL → parent Akte's office-scoped gate
//
// Akte-linked mutations append an akten_events audit row so the Verlauf
// tab shows "Checkliste angelegt/umbenannt/abgehakt". Personal instances
// never touch akten_events.
// Visibility mirrors paliad.termine (projekt_id nullable):
// - projekt_id NULL → creator-only (personal instance)
// - projekt_id NOT NULL → parent Projekt's team-based gate
type ChecklistInstanceService struct {
db *sqlx.DB
akten *AkteService
db *sqlx.DB
projekte *ProjektService
}
func NewChecklistInstanceService(db *sqlx.DB, akten *AkteService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, akten: akten}
func NewChecklistInstanceService(db *sqlx.DB, projekte *ProjektService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projekte: projekte}
}
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.akte_id, ci.state,
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.projekt_id, ci.state,
ci.created_by, ci.created_at, ci.updated_at`
const checklistInstanceWithAkteSelect = `SELECT ` + checklistInstanceColumns + `,
a.aktenzeichen AS akte_aktenzeichen,
a.title AS akte_title
const checklistInstanceWithProjektSelect = `SELECT ` + checklistInstanceColumns + `,
p.reference AS projekt_reference,
p.title AS projekt_title
FROM paliad.checklist_instances ci
LEFT JOIN paliad.akten a ON a.id = ci.akte_id`
LEFT JOIN paliad.projekte p ON p.id = ci.projekt_id`
// CreateInstanceInput is the POST body for creating a new instance.
type CreateInstanceInput struct {
Name string `json:"name"`
AkteID *uuid.UUID `json:"akte_id,omitempty"`
Name string `json:"name"`
ProjektID *uuid.UUID `json:"projekt_id,omitempty"`
}
// UpdateInstanceInput is the PATCH body. Any subset of fields may be
// set. `State` merges into the existing state (per-key upsert) rather
// than replacing it — this keeps concurrent checkbox toggles from
// clobbering each other.
// UpdateInstanceInput is the PATCH body. Any subset of fields may be set.
type UpdateInstanceInput struct {
Name *string `json:"name,omitempty"`
AkteID *uuid.UUID `json:"akte_id,omitempty"`
State map[string]bool `json:"state,omitempty"`
// ClearAkte explicitly unlinks from the current Akte (since sending
// AkteID=nil is indistinguishable from omitting the field).
ClearAkte bool `json:"clear_akte,omitempty"`
Name *string `json:"name,omitempty"`
ProjektID *uuid.UUID `json:"projekt_id,omitempty"`
State map[string]bool `json:"state,omitempty"`
ClearProjekt bool `json:"clear_projekt,omitempty"`
}
// ListForTemplate returns every visible instance of a given template.
func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithAkte, error) {
func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProjekt, error) {
if _, ok := checklisten.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
user, err := s.akten.users.GetByID(ctx, userID)
user, err := s.projekte.Users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.ChecklistInstanceWithAkte{}, nil
return []models.ChecklistInstanceWithProjekt{}, nil
}
query := checklistInstanceWithAkteSelect + `
query := checklistInstanceWithProjektSelect + `
WHERE ci.template_slug = :slug
AND (
(ci.akte_id IS NULL AND ci.created_by = :user_id)
OR (ci.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = :office
OR :user_id = ANY (a.collaborators)
OR :role = 'admin'
))
(ci.projekt_id IS NULL AND ci.created_by = :user_id)
OR (ci.projekt_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
)
ORDER BY ci.updated_at DESC`
args := map[string]any{
"slug": slug,
"user_id": userID,
"office": user.Office,
"role": user.Role,
}
return s.listWithAkte(ctx, query, args)
return s.listWithProjekt(ctx, query, args)
}
// ListForAkte returns every visible instance attached to a given Akte.
// Used by the Akte detail Checklisten tab.
func (s *ChecklistInstanceService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.ChecklistInstanceWithAkte, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
// ListForProjekt returns every visible instance attached to a Projekt.
func (s *ChecklistInstanceService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.ChecklistInstanceWithProjekt, error) {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
query := checklistInstanceWithAkteSelect + `
WHERE ci.akte_id = :akte_id
query := checklistInstanceWithProjektSelect + `
WHERE ci.projekt_id = :projekt_id
ORDER BY ci.updated_at DESC`
return s.listWithAkte(ctx, query, map[string]any{"akte_id": akteID})
return s.listWithProjekt(ctx, query, map[string]any{"projekt_id": projektID})
}
// GetByID returns a single instance with visibility check applied.
@@ -125,8 +103,7 @@ func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.
return inst, nil
}
// Create inserts a new instance. Validates slug against static templates
// and gates by Akte visibility when akte_id is set.
// Create inserts a new instance.
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
if _, ok := checklisten.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
@@ -138,8 +115,8 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
if len(name) > 200 {
return nil, fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
}
if input.AkteID != nil {
if _, err := s.akten.GetByID(ctx, userID, *input.AkteID); err != nil {
if input.ProjektID != nil {
if _, err := s.projekte.GetByID(ctx, userID, *input.ProjektID); err != nil {
return nil, err
}
}
@@ -155,17 +132,17 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.checklist_instances
(id, template_slug, name, akte_id, state, created_by, created_at, updated_at)
(id, template_slug, name, projekt_id, state, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, $6, $6)`,
id, slug, name, input.AkteID, userID, now,
id, slug, name, input.ProjektID, userID, now,
); err != nil {
return nil, fmt.Errorf("insert checklist_instance: %w", err)
}
if input.AkteID != nil {
if input.ProjektID != nil {
desc := fmt.Sprintf("Checkliste \u201E%s\u201C angelegt", name)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *input.AkteID, userID,
if err := insertProjektEvent(ctx, tx, *input.ProjektID, userID,
"checkliste_created", "Checkliste angelegt", descPtr); err != nil {
return nil, err
}
@@ -177,8 +154,6 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
}
// Update applies a partial update (rename, re-link, state merge).
// State is merged per-key into the existing jsonb — toggling one
// checkbox never overwrites the rest.
func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateInstanceInput) (*models.ChecklistInstance, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
@@ -207,18 +182,17 @@ func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.U
renamedTo = &n
}
// akte_id changes are handled via ClearAkte (unlink) or explicit AkteID (relink).
var relinkTo *uuid.UUID
var unlinking bool
if input.ClearAkte {
appendSet("akte_id", nil)
if input.ClearProjekt {
appendSet("projekt_id", nil)
unlinking = true
} else if input.AkteID != nil {
if _, err := s.akten.GetByID(ctx, userID, *input.AkteID); err != nil {
} else if input.ProjektID != nil {
if _, err := s.projekte.GetByID(ctx, userID, *input.ProjektID); err != nil {
return nil, err
}
appendSet("akte_id", *input.AkteID)
relinkTo = input.AkteID
appendSet("projekt_id", *input.ProjektID)
relinkTo = input.ProjektID
}
if len(input.State) > 0 {
@@ -250,26 +224,25 @@ func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.U
return nil, fmt.Errorf("update checklist_instance: %w", err)
}
// Audit: rename / (un)link events on the owning Akte only.
switch {
case renamedTo != nil && current.AkteID != nil:
case renamedTo != nil && current.ProjektID != nil:
desc := fmt.Sprintf("Checkliste umbenannt: \u201E%s\u201C", *renamedTo)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID,
"checkliste_renamed", "Checkliste umbenannt", descPtr); err != nil {
return nil, err
}
case unlinking && current.AkteID != nil:
desc := fmt.Sprintf("Checkliste \u201E%s\u201C von Akte getrennt", current.Name)
case unlinking && current.ProjektID != nil:
desc := fmt.Sprintf("Checkliste \u201E%s\u201C von Projekt getrennt", current.Name)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
"checkliste_unlinked", "Checkliste von Akte getrennt", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID,
"checkliste_unlinked", "Checkliste von Projekt getrennt", descPtr); err != nil {
return nil, err
}
case relinkTo != nil:
desc := fmt.Sprintf("Checkliste \u201E%s\u201C mit Akte verknüpft", current.Name)
desc := fmt.Sprintf("Checkliste \u201E%s\u201C mit Projekt verknüpft", current.Name)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *relinkTo, userID,
if err := insertProjektEvent(ctx, tx, *relinkTo, userID,
"checkliste_linked", "Checkliste verknüpft", descPtr); err != nil {
return nil, err
}
@@ -281,7 +254,7 @@ func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.U
return s.GetByID(ctx, userID, id)
}
// Reset clears all checkbox state on an instance (state = {}).
// Reset clears all checkbox state on an instance.
func (s *ChecklistInstanceService) Reset(ctx context.Context, userID, id uuid.UUID) (*models.ChecklistInstance, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
@@ -300,10 +273,10 @@ func (s *ChecklistInstanceService) Reset(ctx context.Context, userID, id uuid.UU
WHERE id = $2`, now, id); err != nil {
return nil, fmt.Errorf("reset instance: %w", err)
}
if current.AkteID != nil {
if current.ProjektID != nil {
desc := fmt.Sprintf("Checkliste \u201E%s\u201C zurückgesetzt", current.Name)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID,
"checkliste_reset", "Checkliste zurückgesetzt", descPtr); err != nil {
return nil, err
}
@@ -321,7 +294,7 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U
return err
}
if current.CreatedBy != userID {
user, err := s.akten.users.GetByID(ctx, userID)
user, err := s.projekte.Users().GetByID(ctx, userID)
if err != nil {
return err
}
@@ -339,10 +312,10 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U
`DELETE FROM paliad.checklist_instances WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete instance: %w", err)
}
if current.AkteID != nil {
if current.ProjektID != nil {
desc := fmt.Sprintf("Checkliste \u201E%s\u201C gelöscht", current.Name)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID,
"checkliste_deleted", "Checkliste gelöscht", descPtr); err != nil {
return err
}
@@ -352,14 +325,14 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U
// --- internals ------------------------------------------------------------
func (s *ChecklistInstanceService) listWithAkte(ctx context.Context, query string, args map[string]any) ([]models.ChecklistInstanceWithAkte, error) {
func (s *ChecklistInstanceService) listWithProjekt(ctx context.Context, query string, args map[string]any) ([]models.ChecklistInstanceWithProjekt, error) {
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list instances: %w", err)
}
defer stmt.Close()
var rows []models.ChecklistInstanceWithAkte
var rows []models.ChecklistInstanceWithProjekt
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list checklist_instances: %w", err)
}
@@ -369,7 +342,7 @@ func (s *ChecklistInstanceService) listWithAkte(ctx context.Context, query strin
func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.ChecklistInstance, error) {
var inst models.ChecklistInstance
err := s.db.GetContext(ctx, &inst,
`SELECT id, template_slug, name, akte_id, state, created_by, created_at, updated_at
`SELECT id, template_slug, name, projekt_id, state, created_by, created_at, updated_at
FROM paliad.checklist_instances WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
@@ -381,12 +354,12 @@ func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid
}
func (s *ChecklistInstanceService) requireVisible(ctx context.Context, userID uuid.UUID, inst *models.ChecklistInstance) error {
if inst.AkteID == nil {
if inst.ProjektID == nil {
if inst.CreatedBy != userID {
return ErrNotVisible
}
return nil
}
_, err := s.akten.GetByID(ctx, userID, *inst.AkteID)
_, err := s.projekte.GetByID(ctx, userID, *inst.ProjektID)
return err
}

View File

@@ -1,10 +1,8 @@
package services
// DashboardService aggregates the summary payload for the logged-in landing
// page: deadline counts, matter counts, upcoming Fristen/Termine, and the
// recent activity feed. Scoped to Akten the caller can see — same predicate
// as AkteService.ListVisibleForUser (see migration 006 for the canonical SQL
// version).
// DashboardService aggregates the logged-in landing-page payload. Scoped to
// Projekte the caller can see — same predicate as ProjektService (team-based,
// v2 data model, t-paliad-024).
import (
"context"
@@ -19,14 +17,13 @@ import (
"mgit.msbls.de/m/patholo/internal/models"
)
// DashboardService reads paliad.akten/fristen/termine/akten_events to assemble
// the Dashboard payload. Office-scoped through the standard visibility rule.
// DashboardService reads paliad.projekte/fristen/termine/projekt_events for
// the Dashboard page.
type DashboardService struct {
db *sqlx.DB
users *UserService
}
// NewDashboardService wires the service to its deps.
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
return &DashboardService{db: db, users: users}
}
@@ -41,7 +38,6 @@ type DashboardData struct {
RecentActivity []ActivityEntry `json:"recent_activity"`
}
// DashboardUser is the subset of paliad.users the dashboard header renders.
type DashboardUser struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
@@ -50,7 +46,6 @@ type DashboardUser struct {
Role string `json:"role"`
}
// DeadlineSummary is the four traffic-light counts.
type DeadlineSummary struct {
Overdue int `json:"overdue" db:"overdue"`
ThisWeek int `json:"this_week" db:"this_week"`
@@ -58,55 +53,51 @@ type DeadlineSummary struct {
CompletedThisWeek int `json:"completed_this_week" db:"completed_this_week"`
}
// MatterSummary counts of visible Akten by high-level status.
// MatterSummary counts visible Projekte by status. Field names kept as
// "matter" for JSON API compatibility with the dashboard client.
type MatterSummary struct {
Active int `json:"active" db:"active"`
Archived int `json:"archived" db:"archived"`
Total int `json:"total" db:"total"`
}
// UpcomingDeadline is one row for the "Kommende Fristen" column.
// UpcomingDeadline is one row for "Kommende Fristen".
type UpcomingDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
AkteID uuid.UUID `json:"akte_id" db:"akte_id"`
AkteTitle string `json:"akte_title" db:"akte_title"`
AkteRef string `json:"akte_ref" db:"akte_ref"`
Urgency string `json:"urgency"`
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
ProjektID uuid.UUID `json:"projekt_id" db:"projekt_id"`
ProjektTitle string `json:"projekt_title" db:"projekt_title"`
ProjektRef string `json:"projekt_ref" db:"projekt_ref"`
Urgency string `json:"urgency"`
}
// UpcomingAppointment is one row for the "Kommende Termine" column.
// AkteID/Title/Ref are pointers because termine may be ad-hoc (no parent).
// UpcomingAppointment is one row for "Kommende Termine".
type UpcomingAppointment struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
StartAt time.Time `json:"start_at" db:"start_at"`
EndAt *time.Time `json:"end_at" db:"end_at"`
Type *string `json:"type" db:"termin_type"`
AkteID *uuid.UUID `json:"akte_id" db:"akte_id"`
AkteTitle *string `json:"akte_title" db:"akte_title"`
AkteRef *string `json:"akte_ref" db:"akte_ref"`
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
StartAt time.Time `json:"start_at" db:"start_at"`
EndAt *time.Time `json:"end_at" db:"end_at"`
Type *string `json:"type" db:"termin_type"`
ProjektID *uuid.UUID `json:"projekt_id" db:"projekt_id"`
ProjektTitle *string `json:"projekt_title" db:"projekt_title"`
ProjektRef *string `json:"projekt_ref" db:"projekt_ref"`
}
// ActivityEntry is one row in the "Letzte Aktivität" feed.
type ActivityEntry struct {
Timestamp time.Time `json:"timestamp" db:"timestamp"`
ActorEmail *string `json:"actor_email" db:"actor_email"`
ActorName *string `json:"actor_name" db:"actor_name"`
AkteID uuid.UUID `json:"akte_id" db:"akte_id"`
AkteTitle string `json:"akte_title" db:"akte_title"`
AkteRef string `json:"akte_ref" db:"akte_ref"`
Action *string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
Description *string `json:"description" db:"description"`
Timestamp time.Time `json:"timestamp" db:"timestamp"`
ActorEmail *string `json:"actor_email" db:"actor_email"`
ActorName *string `json:"actor_name" db:"actor_name"`
ProjektID uuid.UUID `json:"projekt_id" db:"projekt_id"`
ProjektTitle string `json:"projekt_title" db:"projekt_title"`
ProjektRef string `json:"projekt_ref" db:"projekt_ref"`
Action *string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
Description *string `json:"description" db:"description"`
}
// Get builds the full dashboard payload for the given user.
//
// Returns zero-value summaries and empty lists if the user has no
// paliad.users row yet — brand-new logins still get a valid response so the
// page can render an onboarding hint instead of an error.
// Get builds the full dashboard payload.
func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*DashboardData, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
@@ -151,33 +142,37 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
return data, nil
}
// loadSummary fills DeadlineSummary and MatterSummary in one round-trip using
// CTEs that restrict to visible Akten.
// loadSummary fills DeadlineSummary + MatterSummary.
//
// Visibility predicate: admin OR any ancestor-or-direct team membership.
// Applied once via a CTE; downstream queries reuse the same pattern.
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string, sevenDaysAgo time.Time) error {
query := `
WITH visible_akten AS (
SELECT id, status
FROM paliad.akten
WHERE firm_wide_visible = true
OR owning_office = $1
OR $2::uuid = ANY (collaborators)
OR $3 = 'admin'
WITH visible_projekte AS (
SELECT p.id, p.status
FROM paliad.projekte p
WHERE $2 = 'admin'
OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = $1
AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[])
)
),
deadline_stats AS (
SELECT
COUNT(*) FILTER (WHERE f.due_date < $4::date AND f.status = 'pending') AS overdue,
COUNT(*) FILTER (WHERE f.due_date >= $4::date AND f.due_date <= $5::date AND f.status = 'pending') AS this_week,
COUNT(*) FILTER (WHERE f.due_date > $5::date AND f.status = 'pending') AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed' AND f.completed_at >= $6) AS completed_this_week
COUNT(*) FILTER (WHERE f.due_date < $3::date AND f.status = 'pending') AS overdue,
COUNT(*) FILTER (WHERE f.due_date >= $3::date AND f.due_date <= $4::date AND f.status = 'pending') AS this_week,
COUNT(*) FILTER (WHERE f.due_date > $4::date AND f.status = 'pending') AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed' AND f.completed_at >= $5) AS completed_this_week
FROM paliad.fristen f
JOIN visible_akten v ON v.id = f.akte_id
JOIN visible_projekte v ON v.id = f.projekt_id
),
matter_stats AS (
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE status = 'archived') AS archived,
COUNT(*) AS total
FROM visible_akten
FROM visible_projekte
)
SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
ms.active, ms.archived, ms.total
@@ -188,7 +183,7 @@ SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
MatterSummary
}
err := s.db.GetContext(ctx, &row, query,
user.Office, user.ID, user.Role, today, endOfWeek, sevenDaysAgo)
user.ID, user.Role, today, endOfWeek, sevenDaysAgo)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
@@ -204,94 +199,89 @@ func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *Dash
query := `
SELECT f.id,
f.title,
to_char(f.due_date, 'YYYY-MM-DD') AS due_date,
a.id AS akte_id,
a.title AS akte_title,
a.aktenzeichen AS akte_ref
to_char(f.due_date, 'YYYY-MM-DD') AS due_date,
p.id AS projekt_id,
p.title AS projekt_title,
COALESCE(p.reference, '') AS projekt_ref
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.projekte p ON p.id = f.projekt_id
WHERE f.status = 'pending'
AND f.due_date >= $4::date
AND f.due_date <= $5::date
AND (a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin')
AND f.due_date >= $3::date
AND f.due_date <= $4::date
AND ($2 = 'admin' OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = $1
AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[])
))
ORDER BY f.due_date ASC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
user.Office, user.ID, user.Role, today, endOfWeek); err != nil {
user.ID, user.Role, today, endOfWeek); err != nil {
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
}
return nil
}
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
// Personal Termine (akte_id IS NULL) are creator-only — must mirror
// TerminService.canSee. Akte-attached rows follow the office-scoped
// visibility predicate.
query := `
SELECT t.id,
t.title,
t.start_at,
t.end_at,
t.termin_type,
t.akte_id,
a.title AS akte_title,
a.aktenzeichen AS akte_ref
t.projekt_id,
p.title AS projekt_title,
COALESCE(p.reference, NULL) AS projekt_ref
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
WHERE t.start_at >= $4
AND t.start_at < ($4 + interval '7 days')
AND ((t.akte_id IS NULL AND t.created_by = $2::uuid)
OR (t.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin')))
LEFT JOIN paliad.projekte p ON p.id = t.projekt_id
WHERE t.start_at >= $3
AND t.start_at < ($3 + interval '7 days')
AND (
(t.projekt_id IS NULL AND t.created_by = $1)
OR (t.projekt_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = $1
AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[])
)))
)
ORDER BY t.start_at ASC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
user.Office, user.ID, user.Role, now); err != nil {
user.ID, user.Role, now); err != nil {
return fmt.Errorf("dashboard upcoming appointments: %w", err)
}
return nil
}
func (s *DashboardService) loadRecentActivity(ctx context.Context, data *DashboardData, user *models.User) error {
// Timestamp preference: event_date (explicit) → created_at (fallback).
// actor_email/name come from paliad.users — NULL if the actor never
// onboarded; the UI then falls back to a "System" label.
query := `
SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
u.email AS actor_email,
u.display_name AS actor_name,
e.akte_id,
a.title AS akte_title,
a.aktenzeichen AS akte_ref,
e.projekt_id,
p.title AS projekt_title,
COALESCE(p.reference, '') AS projekt_ref,
e.event_type AS action,
e.title AS details,
e.description
FROM paliad.akten_events e
JOIN paliad.akten a ON a.id = e.akte_id
FROM paliad.projekt_events e
JOIN paliad.projekte p ON p.id = e.projekt_id
LEFT JOIN paliad.users u ON u.id = e.created_by
WHERE a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin'
WHERE $2 = 'admin'
OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = $1
AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[])
)
ORDER BY COALESCE(e.event_date, e.created_at) DESC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.RecentActivity, query,
user.Office, user.ID, user.Role); err != nil {
user.ID, user.Role); err != nil {
return fmt.Errorf("dashboard recent activity: %w", err)
}
return nil
}
// annotateUrgency sets the Urgency bucket on each UpcomingDeadline. Only
// status=pending deadlines with due_date ∈ [today, today+7d] are in the slice,
// but "overdue" is still emitted to be defensive across daylight-saving or
// clock skew between DB and server.
func annotateUrgency(deadlines []UpcomingDeadline, now time.Time) {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := range deadlines {

View File

@@ -0,0 +1,233 @@
package services
// DezernatService handles paliad.dezernate + paliad.dezernat_mitglieder —
// the structural partner-led units. Orthogonal to project teams.
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/models"
"mgit.msbls.de/m/patholo/internal/offices"
)
// DezernatService reads and writes paliad.dezernate.
type DezernatService struct {
db *sqlx.DB
users *UserService
}
// NewDezernatService wires the service.
func NewDezernatService(db *sqlx.DB, users *UserService) *DezernatService {
return &DezernatService{db: db, users: users}
}
// CreateDezernatInput is the payload for Create.
type CreateDezernatInput struct {
Name string `json:"name"`
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
Office string `json:"office"`
}
// UpdateDezernatInput is the partial-update payload.
type UpdateDezernatInput struct {
Name *string `json:"name,omitempty"`
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
Office *string `json:"office,omitempty"`
}
// List returns every Dezernat (readable by any authenticated user — see RLS).
func (s *DezernatService) List(ctx context.Context) ([]models.Dezernat, error) {
var rows []models.Dezernat
err := s.db.SelectContext(ctx, &rows,
`SELECT id, name, lead_user_id, office, created_at, updated_at
FROM paliad.dezernate
ORDER BY office, name`)
if err != nil {
return nil, fmt.Errorf("list dezernate: %w", err)
}
return rows, nil
}
// GetByID returns one Dezernat or (nil, sql.ErrNoRows).
func (s *DezernatService) GetByID(ctx context.Context, id uuid.UUID) (*models.Dezernat, error) {
var d models.Dezernat
err := s.db.GetContext(ctx, &d,
`SELECT id, name, lead_user_id, office, created_at, updated_at
FROM paliad.dezernate WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, sql.ErrNoRows
}
if err != nil {
return nil, fmt.Errorf("get dezernat: %w", err)
}
return &d, nil
}
// Create inserts a Dezernat. Admin-only.
func (s *DezernatService) Create(ctx context.Context, callerID uuid.UUID, input CreateDezernatInput) (*models.Dezernat, error) {
if err := s.requireAdmin(ctx, callerID); err != nil {
return nil, err
}
if strings.TrimSpace(input.Name) == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if !offices.IsValid(input.Office) {
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
}
id := uuid.New()
now := time.Now().UTC()
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.dezernate (id, name, lead_user_id, office, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5)`,
id, input.Name, input.LeadUserID, input.Office, now); err != nil {
return nil, fmt.Errorf("insert dezernat: %w", err)
}
return s.GetByID(ctx, id)
}
// Update applies a partial update. Admin-only.
func (s *DezernatService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdateDezernatInput) (*models.Dezernat, error) {
if err := s.requireAdmin(ctx, callerID); err != nil {
return nil, err
}
current, err := s.GetByID(ctx, id)
if err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Name != nil {
appendSet("name", *input.Name)
}
if input.LeadUserID != nil {
appendSet("lead_user_id", *input.LeadUserID)
}
if input.Office != nil {
if !offices.IsValid(*input.Office) {
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office)
}
appendSet("office", *input.Office)
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, id)
query := fmt.Sprintf("UPDATE paliad.dezernate SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update dezernat: %w", err)
}
return s.GetByID(ctx, id)
}
// Delete removes a Dezernat (cascades memberships). Admin-only.
func (s *DezernatService) Delete(ctx context.Context, callerID, id uuid.UUID) error {
if err := s.requireAdmin(ctx, callerID); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `DELETE FROM paliad.dezernate WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete dezernat: %w", err)
}
return nil
}
// AddMember inserts a (dezernat, user) membership. Admin-only. Idempotent.
func (s *DezernatService) AddMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error {
if err := s.requireAdmin(ctx, callerID); err != nil {
return err
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.dezernat_mitglieder (dezernat_id, user_id, created_at)
VALUES ($1, $2, now()) ON CONFLICT (dezernat_id, user_id) DO NOTHING`,
dezernatID, userID)
if err != nil {
return fmt.Errorf("add dezernat member: %w", err)
}
return nil
}
// RemoveMember deletes a (dezernat, user) membership. Admin-only.
func (s *DezernatService) RemoveMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error {
if err := s.requireAdmin(ctx, callerID); err != nil {
return err
}
_, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.dezernat_mitglieder WHERE dezernat_id = $1 AND user_id = $2`,
dezernatID, userID)
if err != nil {
return fmt.Errorf("remove dezernat member: %w", err)
}
return nil
}
// ListMembers returns users in the Dezernat, enriched with display fields.
type DezernatMember 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"`
Role string `db:"role" json:"role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ListMembers returns users in the Dezernat (readable by any authenticated user).
func (s *DezernatService) ListMembers(ctx context.Context, dezernatID uuid.UUID) ([]DezernatMember, error) {
var rows []DezernatMember
err := s.db.SelectContext(ctx, &rows,
`SELECT dm.user_id, dm.created_at,
u.email, u.display_name, u.office, u.role
FROM paliad.dezernat_mitglieder dm
LEFT JOIN paliad.users u ON u.id = dm.user_id
WHERE dm.dezernat_id = $1
ORDER BY u.display_name`, dezernatID)
if err != nil {
return nil, fmt.Errorf("list dezernat members: %w", err)
}
return rows, nil
}
// GetMembership returns the user's Dezernat memberships (zero or more).
// Used by the settings page to render "Your Dezernat: <name>".
func (s *DezernatService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Dezernat, error) {
var rows []models.Dezernat
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at
FROM paliad.dezernate d
JOIN paliad.dezernat_mitglieder dm ON dm.dezernat_id = d.id
WHERE dm.user_id = $1
ORDER BY d.name`, userID)
if err != nil {
return nil, fmt.Errorf("get user dezernat memberships: %w", err)
}
return rows, nil
}
// ---------------------------------------------------------------------------
func (s *DezernatService) requireAdmin(ctx context.Context, userID uuid.UUID) error {
u, err := s.users.GetByID(ctx, userID)
if err != nil {
return err
}
if u == nil || u.Role != "admin" {
return fmt.Errorf("%w: admin required", ErrForbidden)
}
return nil
}

View File

@@ -14,24 +14,23 @@ import (
"mgit.msbls.de/m/patholo/internal/models"
)
// FristService reads and writes paliad.fristen. Visibility is inherited
// from the parent Akte via AkteService.GetByID — every read or write is
// gated on that check first, so an unauthorised user sees ErrNotVisible
// before any Frist data is touched.
// FristService reads and writes paliad.fristen. Visibility inherits from the
// parent Projekt via ProjektService.GetByID — every read or write goes through
// that gate first.
//
// Audit: every mutation appends an akten_events row through
// insertAkteEvent so the Akte verlauf shows what changed.
// Audit: every mutation appends a paliad.projekt_events row via
// insertProjektEvent so the Projekt verlauf shows what changed.
type FristService struct {
db *sqlx.DB
akten *AkteService
db *sqlx.DB
projekte *ProjektService
}
// NewFristService wires the service to its dependencies.
func NewFristService(db *sqlx.DB, akten *AkteService) *FristService {
return &FristService{db: db, akten: akten}
// NewFristService wires the service.
func NewFristService(db *sqlx.DB, projekte *ProjektService) *FristService {
return &FristService{db: db, projekte: projekte}
}
const fristColumns = `id, akte_id, title, description, due_date, original_due_date,
const fristColumns = `id, projekt_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at`
@@ -69,37 +68,29 @@ const (
// ListFilter narrows ListVisibleForUser results.
type ListFilter struct {
Status FristStatusFilter
AkteID *uuid.UUID
Status FristStatusFilter
ProjektID *uuid.UUID
}
// ListVisibleForUser returns Fristen on every Akte the user can see, joined
// with the parent Akte's reference fields so the UI can render a list
// without a per-row fetch. Sorted by due_date ascending (oldest first).
func (s *FristService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.FristWithAkte, error) {
// ListVisibleForUser returns Fristen on every Projekt the user can see,
// joined with parent-Projekt display fields. Sorted by due_date ascending.
func (s *FristService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.FristWithProjekt, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.FristWithAkte{}, nil
return []models.FristWithProjekt{}, nil
}
conds := []string{
`(a.firm_wide_visible = true
OR a.owning_office = :office
OR :user_id = ANY (a.collaborators)
OR :role = 'admin')`,
}
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"office": user.Office,
"user_id": userID,
"role": user.Role,
}
if filter.AkteID != nil {
conds = append(conds, `f.akte_id = :akte_id`)
args["akte_id"] = *filter.AkteID
if filter.ProjektID != nil {
conds = append(conds, `f.projekt_id = :projekt_id`)
args["projekt_id"] = *filter.ProjektID
}
now := time.Now().UTC()
@@ -122,22 +113,22 @@ func (s *FristService) ListVisibleForUser(ctx context.Context, userID uuid.UUID,
case FristFilterPending:
conds = append(conds, `f.status = 'pending'`)
case FristFilterAll, "":
// no-op — all visible
// no-op
default:
return nil, fmt.Errorf("%w: unknown status filter %q", ErrInvalidInput, filter.Status)
}
query := `
SELECT f.id, f.akte_id, f.title, f.description, f.due_date, f.original_due_date,
SELECT f.id, f.projekt_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
a.aktenzeichen AS akte_aktenzeichen,
a.title AS akte_title,
a.owning_office AS akte_office,
r.code AS rule_code
p.reference AS projekt_reference,
p.title AS projekt_title,
p.type AS projekt_type,
r.code AS rule_code
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.projekte p ON p.id = f.projekt_id
LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY f.due_date ASC, f.created_at DESC`
@@ -148,36 +139,36 @@ func (s *FristService) ListVisibleForUser(ctx context.Context, userID uuid.UUID,
}
defer stmt.Close()
var rows []models.FristWithAkte
var rows []models.FristWithProjekt
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list fristen: %w", err)
}
return rows, nil
}
// ListForAkte returns Fristen for a specific Akte (visibility-checked).
func (s *FristService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.Frist, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
// ListForProjekt returns Fristen for a specific Projekt (visibility-checked).
func (s *FristService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Frist, error) {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var rows []models.Frist
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+fristColumns+`
FROM paliad.fristen
WHERE akte_id = $1
ORDER BY due_date ASC, created_at DESC`, akteID); err != nil {
return nil, fmt.Errorf("list fristen for akte: %w", err)
WHERE projekt_id = $1
ORDER BY due_date ASC, created_at DESC`, projektID); err != nil {
return nil, fmt.Errorf("list fristen for projekt: %w", err)
}
return rows, nil
}
// GetByID returns a single Frist, with its parent Akte's visibility checked.
// GetByID returns a single Frist, with parent Projekt visibility checked.
func (s *FristService) GetByID(ctx context.Context, userID, fristID uuid.UUID) (*models.Frist, error) {
akteID, err := s.parentAkteID(ctx, fristID)
projektID, err := s.parentProjektID(ctx, fristID)
if err != nil {
return nil, err
}
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var f models.Frist
@@ -188,25 +179,25 @@ func (s *FristService) GetByID(ctx context.Context, userID, fristID uuid.UUID) (
return &f, nil
}
// Create inserts a single Frist under an Akte.
func (s *FristService) Create(ctx context.Context, userID, akteID uuid.UUID, input CreateFristInput) (*models.Frist, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
// Create inserts a single Frist under a Projekt.
func (s *FristService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (*models.Frist, error) {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
id, err := s.insert(ctx, userID, akteID, input)
id, err := s.insert(ctx, userID, projektID, input)
if err != nil {
return nil, err
}
return s.GetByID(ctx, userID, id)
}
// CreateBulk inserts multiple Fristen under one Akte in a single transaction
// (used by the Fristenrechner "Als Frist(en) speichern" flow).
func (s *FristService) CreateBulk(ctx context.Context, userID, akteID uuid.UUID, inputs []CreateFristInput) ([]models.Frist, error) {
// CreateBulk inserts multiple Fristen under one Projekt in a single
// transaction (Fristenrechner "Als Frist(en) speichern" flow).
func (s *FristService) CreateBulk(ctx context.Context, userID, projektID uuid.UUID, inputs []CreateFristInput) ([]models.Frist, error) {
if len(inputs) == 0 {
return nil, fmt.Errorf("%w: at least one Frist is required", ErrInvalidInput)
}
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
@@ -218,7 +209,7 @@ func (s *FristService) CreateBulk(ctx context.Context, userID, akteID uuid.UUID,
ids := make([]uuid.UUID, 0, len(inputs))
for _, in := range inputs {
id, err := s.insertTx(ctx, tx, userID, akteID, in)
id, err := s.insertTx(ctx, tx, userID, projektID, in)
if err != nil {
return nil, err
}
@@ -226,7 +217,7 @@ func (s *FristService) CreateBulk(ctx context.Context, userID, akteID uuid.UUID,
}
desc := fmt.Sprintf("%d Fristen aus Fristenrechner übernommen", len(inputs))
if err := insertAkteEvent(ctx, tx, akteID, userID, "fristen_imported", desc, nil); err != nil {
if err := insertProjektEvent(ctx, tx, projektID, userID, "fristen_imported", desc, nil); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -310,10 +301,9 @@ func (s *FristService) Update(ctx context.Context, userID, fristID uuid.UUID, in
return nil, fmt.Errorf("update frist: %w", err)
}
// Audit
desc := fmt.Sprintf("Frist \u201E%s\u201C geändert", current.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, current.AkteID, userID, "frist_updated", "Frist geändert", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, current.ProjektID, userID, "frist_updated", "Frist geändert", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -322,7 +312,7 @@ func (s *FristService) Update(ctx context.Context, userID, fristID uuid.UUID, in
return s.GetByID(ctx, userID, fristID)
}
// Complete marks a Frist as completed (status=completed, completed_at=now).
// Complete marks a Frist as completed.
func (s *FristService) Complete(ctx context.Context, userID, fristID uuid.UUID) (*models.Frist, error) {
current, err := s.GetByID(ctx, userID, fristID)
if err != nil {
@@ -347,7 +337,7 @@ func (s *FristService) Complete(ctx context.Context, userID, fristID uuid.UUID)
}
desc := fmt.Sprintf("Frist \u201E%s\u201C als erledigt markiert", current.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, current.AkteID, userID, "frist_completed", "Frist erledigt", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, current.ProjektID, userID, "frist_completed", "Frist erledigt", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -356,9 +346,7 @@ func (s *FristService) Complete(ctx context.Context, userID, fristID uuid.UUID)
return s.GetByID(ctx, userID, fristID)
}
// Delete removes a Frist (hard delete Fristen are not archived because
// the audit trail in akten_events keeps the historical reference).
// Partner/admin only, matching the AkteService delete policy.
// Delete hard-deletes a Frist. Partner/admin only.
func (s *FristService) Delete(ctx context.Context, userID, fristID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
@@ -387,14 +375,13 @@ func (s *FristService) Delete(ctx context.Context, userID, fristID uuid.UUID) er
}
desc := fmt.Sprintf("Frist \u201E%s\u201C gelöscht", current.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, current.AkteID, userID, "frist_deleted", "Frist gelöscht", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, current.ProjektID, userID, "frist_deleted", "Frist gelöscht", descPtr); err != nil {
return err
}
return tx.Commit()
}
// SummaryCounts returns traffic-light counts (overdue / this_week / upcoming
// / completed) for the user's visible Fristen. Single query, fast.
// SummaryCounts returns traffic-light counts across the user's visible Fristen.
type SummaryCounts struct {
Overdue int `json:"overdue"`
ThisWeek int `json:"this_week"`
@@ -403,7 +390,9 @@ type SummaryCounts struct {
Total int `json:"total"`
}
func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, akteID *uuid.UUID) (*SummaryCounts, error) {
// SummaryCounts aggregates Fristen by due-date bucket for the user's visible
// projects, optionally scoped to a single Projekt.
func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, projektID *uuid.UUID) (*SummaryCounts, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
@@ -415,33 +404,27 @@ func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, akte
today := now.Truncate(24 * time.Hour)
endWeek := today.AddDate(0, 0, 7)
conds := []string{
`(a.firm_wide_visible = true
OR a.owning_office = :office
OR :user_id = ANY (a.collaborators)
OR :role = 'admin')`,
}
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"office": user.Office,
"user_id": userID,
"role": user.Role,
"today": today,
"endweek": endWeek,
}
if akteID != nil {
conds = append(conds, `f.akte_id = :akte_id`)
args["akte_id"] = *akteID
if projektID != nil {
conds = append(conds, `f.projekt_id = :projekt_id`)
args["projekt_id"] = *projektID
}
query := `
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :endweek) AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :endweek) AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.projekte p ON p.id = f.projekt_id
WHERE ` + strings.Join(conds, " AND ")
stmt, err := s.db.PrepareNamedContext(ctx, query)
@@ -457,22 +440,22 @@ func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, akte
return &c, nil
}
// insert performs a single INSERT in its own transaction. Used by Create().
func (s *FristService) insert(ctx context.Context, userID, akteID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
// insert performs one INSERT in its own transaction.
func (s *FristService) insert(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return uuid.Nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
id, err := s.insertTx(ctx, tx, userID, akteID, input)
id, err := s.insertTx(ctx, tx, userID, projektID, input)
if err != nil {
return uuid.Nil, err
}
desc := fmt.Sprintf("Frist \u201E%s\u201C angelegt", strings.TrimSpace(input.Title))
descPtr := &desc
if err := insertAkteEvent(ctx, tx, akteID, userID, "frist_created", "Frist angelegt", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, projektID, userID, "frist_created", "Frist angelegt", descPtr); err != nil {
return uuid.Nil, err
}
if err := tx.Commit(); err != nil {
@@ -481,10 +464,8 @@ func (s *FristService) insert(ctx context.Context, userID, akteID uuid.UUID, inp
return id, nil
}
// insertTx writes one fristen row inside an existing transaction. The caller
// is responsible for emitting the akten_events row (Create does it per-row,
// CreateBulk does it once for the whole batch).
func (s *FristService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, akteID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
// insertTx writes one fristen row in an existing transaction.
func (s *FristService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return uuid.Nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
@@ -513,10 +494,10 @@ func (s *FristService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, akteID
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.fristen
(id, akte_id, title, description, due_date, original_due_date,
(id, projekt_id, title, description, due_date, original_due_date,
source, rule_id, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10, $11, $11)`,
id, akteID, title, input.Description, due, orig,
id, projektID, title, input.Description, due, orig,
source, input.RuleID, input.Notes, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert frist: %w", err)
@@ -524,25 +505,24 @@ func (s *FristService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, akteID
return id, nil
}
// parentAkteID resolves a Frist's parent Akte ID without a visibility check.
// Used internally before delegating to AkteService.GetByID for the gate.
func (s *FristService) parentAkteID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) {
var akteID uuid.UUID
err := s.db.GetContext(ctx, &akteID,
`SELECT akte_id FROM paliad.fristen WHERE id = $1`, fristID)
// parentProjektID resolves a Frist's parent Projekt ID without a visibility
// check. Internal only — callers must then gate via ProjektService.GetByID.
func (s *FristService) parentProjektID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) {
var projektID uuid.UUID
err := s.db.GetContext(ctx, &projektID,
`SELECT projekt_id FROM paliad.fristen WHERE id = $1`, fristID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotVisible
}
if err != nil {
return uuid.Nil, fmt.Errorf("lookup frist parent: %w", err)
}
return akteID, nil
return projektID, nil
}
// users returns the AkteService's user service. The FristService doesn't
// hold its own pointer because it always goes through the Akte gate.
// users returns the shared user service via the ProjektService handle.
func (s *FristService) users() *UserService {
return s.akten.users
return s.projekte.Users()
}
func isValidFristStatus(st string) bool {

View File

@@ -15,32 +15,22 @@ import (
)
// NotizService reads and writes paliad.notizen — polymorphic notes anchored
// to exactly one of { Akte, Frist, Termin, AktenEvent }. Visibility follows
// the parent row:
// to exactly one of { Projekt, Frist, Termin, ProjektEvent }. Visibility
// follows the parent row.
//
// - akte_id set → AkteService.GetByID (office-scoped)
// - frist_id set → parent Frist's Akte (same gate)
// - termin_id set → personal if Termin.akte_id is NULL (creator-only),
// otherwise the parent Akte gate
// - akten_event_id set → parent event's Akte gate
//
// Edit rights: only the author (created_by) may edit their own note.
// Delete rights: author, or any partner/admin.
//
// Audit: notes attached to an Akte (directly or transitively via frist/
// termin/event) append an akten_events row so the Verlauf tab shows the
// activity. Personal Termin notes never touch akten_events.
// Edit: only the author (created_by) may edit their own note.
// Delete: author, or partner/admin.
type NotizService struct {
db *sqlx.DB
akten *AkteService
termin *TerminService
db *sqlx.DB
projekte *ProjektService
termin *TerminService
}
func NewNotizService(db *sqlx.DB, akten *AkteService, termin *TerminService) *NotizService {
return &NotizService{db: db, akten: akten, termin: termin}
func NewNotizService(db *sqlx.DB, projekte *ProjektService, termin *TerminService) *NotizService {
return &NotizService{db: db, projekte: projekte, termin: termin}
}
const notizColumns = `n.id, n.akte_id, n.frist_id, n.termin_id, n.akten_event_id,
const notizColumns = `n.id, n.projekt_id, n.frist_id, n.termin_id, n.akten_event_id,
n.content, n.created_by, n.created_at, n.updated_at,
u.display_name AS author_name,
u.email AS author_email`
@@ -49,35 +39,31 @@ const notizSelect = `SELECT ` + notizColumns + `
FROM paliad.notizen n
LEFT JOIN paliad.users u ON u.id = n.created_by`
// CreateNotizInput is the POST payload. The parent is supplied via the URL
// (see handlers). Callers pass exactly one of akte_id/frist_id/termin_id/
// akten_event_id — the DB CHECK enforces the invariant too.
// CreateNotizInput is the POST payload.
type CreateNotizInput struct {
Content string `json:"content"`
}
// UpdateNotizInput is the PATCH payload. Only content can be edited.
// UpdateNotizInput is the PATCH payload.
type UpdateNotizInput struct {
Content *string `json:"content,omitempty"`
}
// ListForAkte returns all notes attached directly to the given Akte.
// Notes attached to the Akte's Fristen / Termine / Events are surfaced
// on those detail pages, not here.
func (s *NotizService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.Notiz, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
// ListForProjekt returns all notes attached directly to the given Projekt.
func (s *NotizService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Notiz, error) {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
return s.list(ctx, `n.akte_id = $1`, akteID)
return s.list(ctx, `n.projekt_id = $1`, projektID)
}
// ListForFrist returns all notes attached to a specific Frist.
func (s *NotizService) ListForFrist(ctx context.Context, userID, fristID uuid.UUID) ([]models.Notiz, error) {
akteID, err := s.fristAkteID(ctx, fristID)
projektID, err := s.fristProjektID(ctx, fristID)
if err != nil {
return nil, err
}
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
return s.list(ctx, `n.frist_id = $1`, fristID)
@@ -91,28 +77,28 @@ func (s *NotizService) ListForTermin(ctx context.Context, userID, terminID uuid.
return s.list(ctx, `n.termin_id = $1`, terminID)
}
// ListForAktenEvent returns all notes attached to a specific event.
func (s *NotizService) ListForAktenEvent(ctx context.Context, userID, eventID uuid.UUID) ([]models.Notiz, error) {
akteID, err := s.eventAkteID(ctx, eventID)
// ListForProjektEvent returns all notes attached to a specific projekt_event row.
func (s *NotizService) ListForProjektEvent(ctx context.Context, userID, eventID uuid.UUID) ([]models.Notiz, error) {
projektID, err := s.eventProjektID(ctx, eventID)
if err != nil {
return nil, err
}
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
return s.list(ctx, `n.akten_event_id = $1`, eventID)
}
// CreateForAkte inserts a note attached directly to an Akte.
func (s *NotizService) CreateForAkte(ctx context.Context, userID, akteID uuid.UUID, input CreateNotizInput) (*models.Notiz, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
// CreateForProjekt inserts a note attached directly to a Projekt.
func (s *NotizService) CreateForProjekt(ctx context.Context, userID, projektID uuid.UUID, input CreateNotizInput) (*models.Notiz, error) {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
content, err := validateContent(input.Content)
if err != nil {
return nil, err
}
id, err := s.insertWithAudit(ctx, userID, content, notizParent{AkteID: &akteID}, &akteID, "akte")
id, err := s.insertWithAudit(ctx, userID, content, notizParent{ProjektID: &projektID}, &projektID, "projekt")
if err != nil {
return nil, err
}
@@ -121,18 +107,18 @@ func (s *NotizService) CreateForAkte(ctx context.Context, userID, akteID uuid.UU
// CreateForFrist inserts a note attached to a Frist.
func (s *NotizService) CreateForFrist(ctx context.Context, userID, fristID uuid.UUID, input CreateNotizInput) (*models.Notiz, error) {
akteID, err := s.fristAkteID(ctx, fristID)
projektID, err := s.fristProjektID(ctx, fristID)
if err != nil {
return nil, err
}
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
content, err := validateContent(input.Content)
if err != nil {
return nil, err
}
id, err := s.insertWithAudit(ctx, userID, content, notizParent{FristID: &fristID}, &akteID, "frist")
id, err := s.insertWithAudit(ctx, userID, content, notizParent{FristID: &fristID}, &projektID, "frist")
if err != nil {
return nil, err
}
@@ -140,7 +126,7 @@ func (s *NotizService) CreateForFrist(ctx context.Context, userID, fristID uuid.
}
// CreateForTermin inserts a note attached to a Termin. Personal Termin
// notes are creator-only. Akte-attached Termin notes append akten_events.
// notes skip the audit trail; Projekt-attached Termin notes append events.
func (s *NotizService) CreateForTermin(ctx context.Context, userID, terminID uuid.UUID, input CreateNotizInput) (*models.Notiz, error) {
t, err := s.termin.GetByID(ctx, userID, terminID)
if err != nil {
@@ -150,7 +136,7 @@ func (s *NotizService) CreateForTermin(ctx context.Context, userID, terminID uui
if err != nil {
return nil, err
}
id, err := s.insertWithAudit(ctx, userID, content, notizParent{TerminID: &terminID}, t.AkteID, "termin")
id, err := s.insertWithAudit(ctx, userID, content, notizParent{TerminID: &terminID}, t.ProjektID, "termin")
if err != nil {
return nil, err
}
@@ -202,7 +188,7 @@ func (s *NotizService) Delete(ctx context.Context, userID, id uuid.UUID) error {
}
isAuthor := current.CreatedBy != nil && *current.CreatedBy == userID
if !isAuthor {
user, err := s.akten.users.GetByID(ctx, userID)
user, err := s.projekte.Users().GetByID(ctx, userID)
if err != nil {
return err
}
@@ -220,13 +206,12 @@ func (s *NotizService) Delete(ctx context.Context, userID, id uuid.UUID) error {
// --- internals -------------------------------------------------------------
type notizParent struct {
AkteID *uuid.UUID
ProjektID *uuid.UUID
FristID *uuid.UUID
TerminID *uuid.UUID
AktenEventID *uuid.UUID
}
// list runs the shared SELECT with an additional WHERE clause and ORDER BY.
func (s *NotizService) list(ctx context.Context, where string, arg any) ([]models.Notiz, error) {
query := notizSelect + ` WHERE ` + where + ` ORDER BY n.created_at DESC`
var rows []models.Notiz
@@ -236,9 +221,9 @@ func (s *NotizService) list(ctx context.Context, where string, arg any) ([]model
return rows, nil
}
// insertWithAudit inserts one notizen row and, when an owning Akte exists,
// appends an akten_events audit row in the same transaction.
func (s *NotizService) insertWithAudit(ctx context.Context, userID uuid.UUID, content string, parent notizParent, akteAuditID *uuid.UUID, parentLabel string) (uuid.UUID, error) {
// insertWithAudit inserts one notizen row and, when an owning Projekt exists,
// appends a projekt_events audit row in the same transaction.
func (s *NotizService) insertWithAudit(ctx context.Context, userID uuid.UUID, content string, parent notizParent, projektAuditID *uuid.UUID, parentLabel string) (uuid.UUID, error) {
id := uuid.New()
now := time.Now().UTC()
@@ -250,20 +235,20 @@ func (s *NotizService) insertWithAudit(ctx context.Context, userID uuid.UUID, co
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.notizen
(id, akte_id, frist_id, termin_id, akten_event_id,
(id, projekt_id, frist_id, termin_id, akten_event_id,
content, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,
id, parent.AkteID, parent.FristID, parent.TerminID, parent.AktenEventID,
id, parent.ProjektID, parent.FristID, parent.TerminID, parent.AktenEventID,
content, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert notiz: %w", err)
}
if akteAuditID != nil {
if projektAuditID != nil {
title := "Notiz hinzugef\u00fcgt"
desc := fmt.Sprintf("Notiz zu %s hinzugef\u00fcgt", parentLabel)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *akteAuditID, userID, "notiz_created", title, descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, *projektAuditID, userID, "notiz_created", title, descPtr); err != nil {
return uuid.Nil, err
}
}
@@ -273,8 +258,7 @@ func (s *NotizService) insertWithAudit(ctx context.Context, userID uuid.UUID, co
return id, nil
}
// getByIDUnchecked fetches a note without a visibility check. Callers that
// expose the row to a user must call requireVisible first.
// getByIDUnchecked fetches a note without a visibility check.
func (s *NotizService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.Notiz, error) {
var n models.Notiz
err := s.db.GetContext(ctx, &n, notizSelect+` WHERE n.id = $1`, id)
@@ -287,59 +271,58 @@ func (s *NotizService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*mod
return &n, nil
}
// requireVisible re-runs the parent-visibility check used by list endpoints
// so PATCH/DELETE on a single Notiz enforces the same rules as GET.
// requireVisible re-runs the parent-visibility check.
func (s *NotizService) requireVisible(ctx context.Context, userID uuid.UUID, n *models.Notiz) error {
switch {
case n.AkteID != nil:
_, err := s.akten.GetByID(ctx, userID, *n.AkteID)
case n.ProjektID != nil:
_, err := s.projekte.GetByID(ctx, userID, *n.ProjektID)
return err
case n.FristID != nil:
akteID, err := s.fristAkteID(ctx, *n.FristID)
projektID, err := s.fristProjektID(ctx, *n.FristID)
if err != nil {
return err
}
_, err = s.akten.GetByID(ctx, userID, akteID)
_, err = s.projekte.GetByID(ctx, userID, projektID)
return err
case n.TerminID != nil:
_, err := s.termin.GetByID(ctx, userID, *n.TerminID)
return err
case n.AktenEventID != nil:
akteID, err := s.eventAkteID(ctx, *n.AktenEventID)
projektID, err := s.eventProjektID(ctx, *n.AktenEventID)
if err != nil {
return err
}
_, err = s.akten.GetByID(ctx, userID, akteID)
_, err = s.projekte.GetByID(ctx, userID, projektID)
return err
default:
return ErrNotVisible
}
}
func (s *NotizService) fristAkteID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) {
var akteID uuid.UUID
err := s.db.GetContext(ctx, &akteID,
`SELECT akte_id FROM paliad.fristen WHERE id = $1`, fristID)
func (s *NotizService) fristProjektID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) {
var projektID uuid.UUID
err := s.db.GetContext(ctx, &projektID,
`SELECT projekt_id FROM paliad.fristen WHERE id = $1`, fristID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotVisible
}
if err != nil {
return uuid.Nil, fmt.Errorf("lookup frist parent: %w", err)
}
return akteID, nil
return projektID, nil
}
func (s *NotizService) eventAkteID(ctx context.Context, eventID uuid.UUID) (uuid.UUID, error) {
var akteID uuid.UUID
err := s.db.GetContext(ctx, &akteID,
`SELECT akte_id FROM paliad.akten_events WHERE id = $1`, eventID)
func (s *NotizService) eventProjektID(ctx context.Context, eventID uuid.UUID) (uuid.UUID, error) {
var projektID uuid.UUID
err := s.db.GetContext(ctx, &projektID,
`SELECT projekt_id FROM paliad.projekt_events WHERE id = $1`, eventID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotVisible
}
if err != nil {
return uuid.Nil, fmt.Errorf("lookup event parent: %w", err)
}
return akteID, nil
return projektID, nil
}
func validateContent(raw string) (string, error) {

View File

@@ -15,20 +15,19 @@ import (
"mgit.msbls.de/m/patholo/internal/models"
)
// ParteienService reads and writes paliad.parteien. Visibility is inherited
// from the parent Akte — the service asks AkteService.GetByID first so a user
// without visibility sees ErrNotVisible instead of any Parteien.
// ParteienService reads and writes paliad.parteien. Visibility inherits from
// the parent Projekt.
type ParteienService struct {
db *sqlx.DB
akten *AkteService
db *sqlx.DB
projekte *ProjektService
}
// NewParteienService wires the service to its dependencies.
func NewParteienService(db *sqlx.DB, akten *AkteService) *ParteienService {
return &ParteienService{db: db, akten: akten}
// NewParteienService wires the service.
func NewParteienService(db *sqlx.DB, projekte *ProjektService) *ParteienService {
return &ParteienService{db: db, projekte: projekte}
}
const parteiColumns = `id, akte_id, name, role, representative, contact_info,
const parteiColumns = `id, projekt_id, name, role, representative, contact_info,
created_at, updated_at`
// CreateParteiInput is the payload for Create.
@@ -39,28 +38,28 @@ type CreateParteiInput struct {
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
// ListForAkte returns all Parteien for the Akte, visibility-checked.
func (s *ParteienService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.Partei, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
// ListForProjekt returns all Parteien for the Projekt, visibility-checked.
func (s *ParteienService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Partei, error) {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var rows []models.Partei
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+parteiColumns+`
FROM paliad.parteien
WHERE akte_id = $1
ORDER BY name`, akteID); err != nil {
WHERE projekt_id = $1
ORDER BY name`, projektID); err != nil {
return nil, fmt.Errorf("list parteien: %w", err)
}
return rows, nil
}
// Create inserts a Partei under an Akte; visibility is checked on the parent.
func (s *ParteienService) Create(ctx context.Context, userID, akteID uuid.UUID, input CreateParteiInput) (*models.Partei, error) {
// Create inserts a Partei under a Projekt; visibility is checked on the parent.
func (s *ParteienService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateParteiInput) (*models.Partei, error) {
if strings.TrimSpace(input.Name) == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
@@ -73,10 +72,10 @@ func (s *ParteienService) Create(ctx context.Context, userID, akteID uuid.UUID,
now := time.Now().UTC()
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.parteien
(id, akte_id, name, role, representative, contact_info,
(id, projekt_id, name, role, representative, contact_info,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,
id, akteID, input.Name, input.Role, input.Representative, contact, now,
id, projektID, input.Name, input.Role, input.Representative, contact, now,
); err != nil {
return nil, fmt.Errorf("insert partei: %w", err)
}
@@ -89,11 +88,9 @@ func (s *ParteienService) Create(ctx context.Context, userID, akteID uuid.UUID,
return &p, nil
}
// Delete removes a Partei. Partner/admin only — mirrors the FristService
// delete policy so associates can't erase a Klägerin record on a firm-wide
// Akte they merely have visibility for.
// Delete removes a Partei. Partner/admin only.
func (s *ParteienService) Delete(ctx context.Context, userID, parteiID uuid.UUID) error {
user, err := s.akten.users.GetByID(ctx, userID)
user, err := s.projekte.Users().GetByID(ctx, userID)
if err != nil {
return err
}
@@ -104,17 +101,16 @@ func (s *ParteienService) Delete(ctx context.Context, userID, parteiID uuid.UUID
return fmt.Errorf("%w: only partners/admins can delete Parteien", ErrForbidden)
}
// Resolve the parent Akte to enforce visibility before DELETE executes.
var akteID uuid.UUID
err = s.db.GetContext(ctx, &akteID,
`SELECT akte_id FROM paliad.parteien WHERE id = $1`, parteiID)
var projektID uuid.UUID
err = s.db.GetContext(ctx, &projektID,
`SELECT projekt_id FROM paliad.parteien WHERE id = $1`, parteiID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotVisible
}
if err != nil {
return fmt.Errorf("lookup partei parent: %w", err)
}
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx,

View File

@@ -0,0 +1,693 @@
package services
// ProjektService handles CRUD on paliad.projekte — the hierarchical
// project tree that replaced the flat paliad.akten model in migration 018.
//
// Visibility (design v2, adjusted 2026-04-20): team-based only.
// A user can see a Projekt iff
// - user is admin, or
// - user is a direct member of the Projekt's team, or
// - user is a member of any ancestor Projekt's team (inherited via path).
//
// Office is no longer a visibility gate. Cases associate with lead partners,
// not offices (see paliad.projekt_teams role='lead').
//
// The canonical predicate lives in SQL (paliad.can_see_projekt) and is
// enforced by RLS policies. This service re-implements the same predicate
// at the application layer so the service-role DB connection (without an
// auth.uid() JWT) still gates correctly.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/models"
)
// Sentinel errors.
var (
// ErrNotVisible indicates the Projekt exists but the user has no
// visibility. Handlers must map to 404 (never leak existence).
ErrNotVisible = errors.New("projekt not visible")
// ErrForbidden indicates the user is authenticated but lacks the role
// required for the operation (e.g., associate trying to delete).
ErrForbidden = errors.New("forbidden")
// ErrInvalidInput signals a bad request (empty required field etc.).
ErrInvalidInput = errors.New("invalid input")
)
// ProjektType values enumerated on the projekte.type CHECK constraint.
const (
ProjektTypeClient = "client"
ProjektTypeLitigation = "litigation"
ProjektTypePatent = "patent"
ProjektTypeCase = "case"
ProjektTypeProject = "project"
)
// ProjektRole values allowed on projekt_teams.role.
const (
RoleLead = "lead"
RoleAssociate = "associate"
RolePA = "pa"
RoleOfCounsel = "of_counsel"
RoleLocalCounsel = "local_counsel"
RoleExpert = "expert"
RoleObserver = "observer"
)
// ProjektService reads and writes paliad.projekte + paliad.projekt_events.
type ProjektService struct {
db *sqlx.DB
users *UserService
}
// NewProjektService wires the service.
func NewProjektService(db *sqlx.DB, users *UserService) *ProjektService {
return &ProjektService{db: db, users: users}
}
// Users exposes the shared user service for downstream services that gate
// through ProjektService (FristService, TerminService, NotizService, …).
func (s *ProjektService) Users() *UserService { return s.users }
// DB exposes the underlying connection pool for services that need to issue
// custom queries (dashboard aggregates, caldav sync). Read-only usage.
func (s *ProjektService) DB() *sqlx.DB { return s.db }
const projektColumns = `id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number, matter_number,
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
proceeding_type_id, metadata, ai_summary, created_at, updated_at`
// CreateProjektInput is the payload for Create.
type CreateProjektInput struct {
Type string `json:"type"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Title string `json:"title"`
Reference *string `json:"reference,omitempty"`
Description *string `json:"description,omitempty"`
Status string `json:"status,omitempty"` // default "active"
// Type-specific; service applies only the subset matching Type.
Industry *string `json:"industry,omitempty"`
Country *string `json:"country,omitempty"`
BillingReference *string `json:"billing_reference,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
MatterNumber *string `json:"matter_number,omitempty"`
NetDocumentsURL *string `json:"netdocuments_url,omitempty"`
PatentNumber *string `json:"patent_number,omitempty"`
FilingDate *time.Time `json:"filing_date,omitempty"`
GrantDate *time.Time `json:"grant_date,omitempty"`
Court *string `json:"court,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
}
// UpdateProjektInput is the partial-update payload.
type UpdateProjektInput struct {
Title *string `json:"title,omitempty"`
Reference *string `json:"reference,omitempty"`
Description *string `json:"description,omitempty"`
Status *string `json:"status,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"` // reparent; server recomputes path
Industry *string `json:"industry,omitempty"`
Country *string `json:"country,omitempty"`
BillingReference *string `json:"billing_reference,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
MatterNumber *string `json:"matter_number,omitempty"`
NetDocumentsURL *string `json:"netdocuments_url,omitempty"`
PatentNumber *string `json:"patent_number,omitempty"`
FilingDate *time.Time `json:"filing_date,omitempty"`
GrantDate *time.Time `json:"grant_date,omitempty"`
Court *string `json:"court,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
}
// ListFilter narrows List results. Zero-value → no filter.
type ProjektFilter struct {
Type string // "", or one of ProjektType* constants
Status string // "", "active", "archived", "closed"
ParentID *uuid.UUID // filter to direct children of the given parent; use ParentNullOnly for roots
// ParentNullOnly restricts to root-level rows (parent_id IS NULL).
// Mutually exclusive with ParentID.
ParentNullOnly bool
Search string // trigram / ILIKE on title, reference, client_number, matter_number
}
// List returns Projekte visible to the user, filterable.
func (s *ProjektService) List(ctx context.Context, userID uuid.UUID, f ProjektFilter) ([]models.Projekt, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.Projekt{}, nil
}
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
}
if f.Type != "" {
conds = append(conds, "p.type = :type")
args["type"] = f.Type
}
if f.Status != "" {
conds = append(conds, "p.status = :status")
args["status"] = f.Status
}
if f.ParentNullOnly {
conds = append(conds, "p.parent_id IS NULL")
} else if f.ParentID != nil {
conds = append(conds, "p.parent_id = :parent_id")
args["parent_id"] = *f.ParentID
}
if s := strings.TrimSpace(f.Search); s != "" {
conds = append(conds, `(p.title ILIKE :search OR p.reference ILIKE :search
OR p.client_number ILIKE :search OR p.matter_number ILIKE :search)`)
args["search"] = "%" + s + "%"
}
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY p.updated_at DESC`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list projekte: %w", err)
}
defer stmt.Close()
var rows []models.Projekt
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list projekte: %w", err)
}
return rows, nil
}
// GetByID returns the Projekt if the user can see it. Returns (nil, ErrNotVisible)
// when invisible or missing — handlers must not distinguish.
func (s *ProjektService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Projekt, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrNotVisible
}
var p models.Projekt
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2, 3)
err = s.db.GetContext(ctx, &p, query, id, userID, user.Role)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("get projekt: %w", err)
}
return &p, nil
}
// ListChildren returns direct children of a Projekt (visibility-checked on parent).
func (s *ProjektService) ListChildren(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) {
if _, err := s.GetByID(ctx, userID, id); err != nil {
return nil, err
}
return s.List(ctx, userID, ProjektFilter{ParentID: &id})
}
// ListAncestors walks up the path and returns ancestors from root → parent
// (exclusive of the Projekt itself). Used for breadcrumbs.
func (s *ProjektService) ListAncestors(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) {
p, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
labels := strings.Split(p.Path, ".")
if len(labels) <= 1 {
return []models.Projekt{}, nil
}
// All but last = ancestors.
ancestorIDs := labels[:len(labels)-1]
ids := make([]uuid.UUID, 0, len(ancestorIDs))
for _, s := range ancestorIDs {
u, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("parse ancestor uuid %q: %w", s, err)
}
ids = append(ids, u)
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.Projekt{}, nil
}
// Ancestors are visible whenever the Projekt is (inheritance works both
// ways through team membership checks). We still apply the predicate
// for safety in case path is stale.
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
WHERE p.id = ANY($1::uuid[]) AND ` +
visibilityPredicatePositional("p", 2, 3)
// lib/pq doesn't serialise []uuid.UUID natively; render as string array.
idStrs := make([]string, len(ids))
for i, u := range ids {
idStrs[i] = u.String()
}
var rows []models.Projekt
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.Role); err != nil {
return nil, fmt.Errorf("list ancestors: %w", err)
}
// Re-order to match path order (root first).
order := make(map[uuid.UUID]int, len(ids))
for i, id := range ids {
order[id] = i
}
sortByOrder(rows, order)
return rows, nil
}
// GetTree returns every Projekt in the subtree rooted at id (inclusive),
// ordered depth-first. Visibility-checked at root; descendants that the
// user can see are returned (the predicate naturally gates sub-branches).
func (s *ProjektService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) {
root, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.Projekt{}, nil
}
// path LIKE root.path || '.%' OR path = root.path
prefix := root.Path + ".%"
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
WHERE (p.path = $1 OR p.path LIKE $2)
AND ` + visibilityPredicatePositional("p", 3, 4) + `
ORDER BY p.path`
var rows []models.Projekt
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.Role); err != nil {
return nil, fmt.Errorf("get tree: %w", err)
}
return rows, nil
}
// Create inserts a new Projekt. If parent_id is set, the creator must have
// visibility on the parent. The creator is auto-added to projekt_teams as
// role='lead' in the same transaction so post-create SELECT picks up the row.
func (s *ProjektService) Create(ctx context.Context, userID uuid.UUID, input CreateProjektInput) (*models.Projekt, error) {
if strings.TrimSpace(input.Title) == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if !isValidProjektType(input.Type) {
return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, input.Type)
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
}
if input.ParentID != nil {
if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
return nil, fmt.Errorf("%w: parent not visible", ErrForbidden)
}
}
status := input.Status
if status == "" {
status = "active"
}
if err := validateProjektStatus(status); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
id := uuid.New()
now := time.Now().UTC()
// path is NOT NULL but the trigger populates it; supply a placeholder
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projekte
(id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number,
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, '{}'::jsonb, $21, $21)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
userID,
input.Industry, input.Country, input.BillingReference,
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
input.PatentNumber, input.FilingDate, input.GrantDate,
input.Court, input.CaseNumber, input.ProceedingTypeID,
now,
); err != nil {
return nil, fmt.Errorf("insert projekt: %w", err)
}
// Auto-add creator as team lead so they (and RLS) can see the row.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by)
VALUES ($1, $2, 'lead', false, $2)`, id, userID); err != nil {
return nil, fmt.Errorf("insert creator team row: %w", err)
}
if err := insertProjektEvent(ctx, tx, id, userID, "projekt_created", "Projekt angelegt", nil); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create projekt: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// Update applies a partial update. Reparenting triggers path rewrite for the
// subtree (handled by the AFTER UPDATE trigger on paliad.projekte).
func (s *ProjektService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateProjektInput) (*models.Projekt, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
if input.ParentID != nil {
// Verify new parent is visible (reparenting under invisible node would
// leak the whole subtree to the new parent's team — reject).
if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
return nil, fmt.Errorf("%w: new parent not visible", ErrForbidden)
}
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Title != nil {
t := strings.TrimSpace(*input.Title)
if t == "" {
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
}
appendSet("title", t)
}
if input.Reference != nil {
appendSet("reference", *input.Reference)
}
if input.Description != nil {
appendSet("description", *input.Description)
}
if input.Status != nil {
if err := validateProjektStatus(*input.Status); err != nil {
return nil, err
}
appendSet("status", *input.Status)
}
if input.ParentID != nil {
appendSet("parent_id", *input.ParentID)
}
if input.Industry != nil {
appendSet("industry", *input.Industry)
}
if input.Country != nil {
appendSet("country", *input.Country)
}
if input.BillingReference != nil {
appendSet("billing_reference", *input.BillingReference)
}
if input.ClientNumber != nil {
appendSet("client_number", *input.ClientNumber)
}
if input.MatterNumber != nil {
appendSet("matter_number", *input.MatterNumber)
}
if input.NetDocumentsURL != nil {
appendSet("netdocuments_url", *input.NetDocumentsURL)
}
if input.PatentNumber != nil {
appendSet("patent_number", *input.PatentNumber)
}
if input.FilingDate != nil {
appendSet("filing_date", *input.FilingDate)
}
if input.GrantDate != nil {
appendSet("grant_date", *input.GrantDate)
}
if input.Court != nil {
appendSet("court", *input.Court)
}
if input.CaseNumber != nil {
appendSet("case_number", *input.CaseNumber)
}
if input.ProceedingTypeID != nil {
appendSet("proceeding_type_id", *input.ProceedingTypeID)
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, id)
query := fmt.Sprintf("UPDATE paliad.projekte SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update projekt: %w", err)
}
if input.Status != nil && *input.Status != current.Status {
desc := fmt.Sprintf("Status %s → %s", current.Status, *input.Status)
if err := insertProjektEvent(ctx, tx, id, userID, "status_changed", desc, nil); err != nil {
return nil, err
}
}
if input.ParentID != nil {
if err := insertProjektEvent(ctx, tx, id, userID, "projekt_reparented", "Projekt neu zugeordnet", nil); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update projekt: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// Delete archives the Projekt (soft-delete, status='archived'). Partner/admin only.
// Hard-delete cascades through FK; we prefer archival for audit.
func (s *ProjektService) Delete(ctx context.Context, userID, id uuid.UUID) error {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
return fmt.Errorf("%w: only partners/admins can archive Projekte", ErrForbidden)
}
if _, err := s.GetByID(ctx, userID, id); err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE paliad.projekte SET status = 'archived', updated_at = $1
WHERE id = $2 AND status != 'archived'`, time.Now().UTC(), id)
if err != nil {
return fmt.Errorf("archive projekt: %w", err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
return tx.Commit()
}
if err := insertProjektEvent(ctx, tx, id, userID, "projekt_archived", "Projekt archiviert", nil); err != nil {
return err
}
return tx.Commit()
}
// MaxEventsPageLimit caps ListEvents page size.
const MaxEventsPageLimit = 200
// DefaultEventsPageLimit is the page size when ?limit= is omitted.
const DefaultEventsPageLimit = 50
// ListEvents returns the audit trail for the Projekt, newest first, with
// cursor pagination (before = uuid of last seen event).
func (s *ProjektService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjektEvent, error) {
if _, err := s.GetByID(ctx, userID, id); err != nil {
return nil, err
}
if limit <= 0 {
limit = DefaultEventsPageLimit
}
if limit > MaxEventsPageLimit {
limit = MaxEventsPageLimit
}
var beforeArg any
if before != nil {
beforeArg = *before
}
var events []models.ProjektEvent
err := s.db.SelectContext(ctx, &events,
`SELECT id, projekt_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at
FROM paliad.projekt_events
WHERE projekt_id = $1
AND ($2::uuid IS NULL OR (created_at, id) < (
SELECT created_at, id FROM paliad.projekt_events WHERE id = $2::uuid
))
ORDER BY created_at DESC, id DESC
LIMIT $3`, id, beforeArg, limit)
if err != nil {
return nil, fmt.Errorf("list projekt events: %w", err)
}
return events, nil
}
// ResolveClientNumber walks up the path to find the first non-null client_number
// (inherited convention). Returns nil if none in the ancestor chain.
func (s *ProjektService) ResolveClientNumber(ctx context.Context, userID, id uuid.UUID) (*string, error) {
p, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
if p.ClientNumber != nil {
return p.ClientNumber, nil
}
ancestors, err := s.ListAncestors(ctx, userID, id)
if err != nil {
return nil, err
}
// Ancestors returned root→parent; scan from closest ancestor outward —
// but client_number is conceptually set at the root, so walking either
// direction is fine. Closest wins for override.
for i := len(ancestors) - 1; i >= 0; i-- {
if ancestors[i].ClientNumber != nil {
return ancestors[i].ClientNumber, nil
}
}
return nil, nil
}
// ============================================================================
// Helpers
// ============================================================================
// visibilityPredicate returns a SQL snippet that gates rows on
// paliad.can_see_projekt-equivalent checks at the application layer. Uses
// named bind variables :user_id and :role.
//
// Predicate: admin OR any (direct or ancestor) team membership of user_id.
// Walks the path: projekt_teams.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]).
func visibilityPredicate(alias string) string {
return `(:role = 'admin' OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = :user_id
AND pt.projekt_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[])
))`
}
// visibilityPredicatePositional returns the same predicate with positional
// placeholders ($userArg, $roleArg). Use when the surrounding query can't
// use named parameters.
func visibilityPredicatePositional(alias string, userArg, roleArg int) string {
return fmt.Sprintf(`($%d = 'admin' OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = $%d
AND pt.projekt_id = ANY(string_to_array(%s.path, '.')::uuid[])
))`, roleArg, userArg, alias)
}
// visibilityPredicatePlaceholder returns the predicate with ? placeholders
// (for sqlx.In-compatible queries). The caller appends (userID, role) to
// args in that order.
func visibilityPredicatePlaceholder(alias string) string {
return `(? = 'admin' OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = ?
AND pt.projekt_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[])
))`
}
// Note: visibilityPredicatePlaceholder expects (role, user_id) in that
// order. Callers must match. We document this inline where used.
// insertProjektEvent appends one audit row in the given tx.
func insertProjektEvent(ctx context.Context, tx *sqlx.Tx, projektID, userID uuid.UUID, eventType, title string, description *string) error {
now := time.Now().UTC()
meta := json.RawMessage(`{}`)
_, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projekt_events
(id, projekt_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $6, $6)`,
uuid.New(), projektID, eventType, title, description, now, userID, meta)
if err != nil {
return fmt.Errorf("insert projekt_event: %w", err)
}
return nil
}
func isValidProjektType(t string) bool {
switch t {
case ProjektTypeClient, ProjektTypeLitigation, ProjektTypePatent,
ProjektTypeCase, ProjektTypeProject:
return true
}
return false
}
func validateProjektStatus(s string) error {
switch s {
case "active", "archived", "closed":
return nil
}
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
}
func sortByOrder(xs []models.Projekt, order map[uuid.UUID]int) {
// Insertion sort — ancestor lists are short (<20).
for i := 1; i < len(xs); i++ {
for j := i; j > 0 && order[xs[j].ID] < order[xs[j-1].ID]; j-- {
xs[j], xs[j-1] = xs[j-1], xs[j]
}
}
}

View File

@@ -172,15 +172,15 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin
SELECT f.id AS frist_id,
f.title AS frist_title,
f.due_date AS due_date,
a.aktenzeichen AS akte_aktenzeichen,
a.title AS akte_title,
COALESCE(a.reference, '') AS akte_aktenzeichen,
a.title AS akte_title,
u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.projekte a ON a.id = f.projekt_id
JOIN paliad.users u ON u.id = f.created_by
WHERE f.status = 'pending'
AND ` + cond + `
@@ -296,9 +296,9 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error
f.id AS frist_id,
f.title AS frist_title,
f.due_date AS due_date,
a.aktenzeichen AS akte_aktenzeichen
COALESCE(a.reference, '') AS akte_aktenzeichen
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.projekte a ON a.id = f.projekt_id
JOIN paliad.users u ON u.id = f.created_by
WHERE f.status = 'pending'
AND f.due_date >= $1

View File

@@ -0,0 +1,196 @@
package services
// TeamService manages paliad.projekt_teams — project team memberships.
//
// Inheritance model (t-paliad-024): a user added at any ancestor of a Projekt
// is implicitly a member of every descendant. Writes only ever touch the
// direct level; inherited memberships are computed at read time by walking
// UP the materialised path.
//
// The `inherited` column in the DB is reserved for potential future caching
// of inherited rows. This service does not write inherited=true rows.
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/models"
)
// TeamService reads and writes paliad.projekt_teams.
type TeamService struct {
db *sqlx.DB
projekte *ProjektService
}
// NewTeamService wires the service.
func NewTeamService(db *sqlx.DB, projekte *ProjektService) *TeamService {
return &TeamService{db: db, projekte: projekte}
}
// AddMember inserts a direct team membership. The caller must have visibility
// on the Projekt (RLS + service-layer gate). Role defaults to 'associate'
// if empty. Idempotent on (projekt_id, user_id) — a repeat call updates role.
func (s *TeamService) AddMember(ctx context.Context, callerID, projektID, userID uuid.UUID, role string) (*models.ProjektTeamMember, error) {
if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil {
return nil, err
}
if role == "" {
role = RoleAssociate
}
if !isValidRole(role) {
return nil, fmt.Errorf("%w: invalid role %q", ErrInvalidInput, role)
}
var m models.ProjektTeamMember
err := s.db.GetContext(ctx, &m,
`INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by)
VALUES ($1, $2, $3, false, $4)
ON CONFLICT (projekt_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, projekt_id, user_id, role, inherited, added_by, created_at`,
projektID, userID, role, callerID)
if err != nil {
return nil, fmt.Errorf("add team member: %w", err)
}
return &m, nil
}
// RemoveMember deletes a direct team membership. Inherited memberships (from
// ancestors) can't be removed at the child level — the caller must remove
// the ancestor row to break the inheritance.
func (s *TeamService) RemoveMember(ctx context.Context, callerID, projektID, userID uuid.UUID) error {
if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil {
return err
}
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.projekt_teams
WHERE projekt_id = $1 AND user_id = $2 AND inherited = false`,
projektID, userID)
if err != nil {
return fmt.Errorf("remove team member: %w", err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
return nil
}
// ListDirectMembers returns only the direct (non-inherited) team members,
// enriched with user display fields.
func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjektTeamMemberWithUser, error) {
if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil {
return nil, err
}
var rows []models.ProjektTeamMemberWithUser
err := s.db.SelectContext(ctx, &rows,
`SELECT pt.id, pt.projekt_id, pt.user_id, pt.role, pt.inherited,
pt.added_by, pt.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
NULL::uuid AS inherited_from_id,
NULL::text AS inherited_from_title
FROM paliad.projekt_teams pt
LEFT JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.projekt_id = $1
ORDER BY pt.role, u.display_name`, projektID)
if err != nil {
return nil, fmt.Errorf("list direct team: %w", err)
}
return rows, nil
}
// ListEffectiveMembers returns direct + inherited members of a Projekt.
// Rows coming from an ancestor carry Inherited=true + InheritedFromID/Title.
// If the same user is both direct and inherited, the direct row wins.
func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjektTeamMemberWithUser, error) {
projekt, err := s.projekte.GetByID(ctx, callerID, projektID)
if err != nil {
return nil, err
}
ancestorIDs := pathToIDStrings(projekt.Path)
query := `
WITH candidate AS (
SELECT pt.id, pt.projekt_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
(pt.projekt_id <> $1) AS inherited,
CASE WHEN pt.projekt_id <> $1 THEN pt.projekt_id END AS inherited_from_id,
CASE WHEN pt.projekt_id <> $1 THEN parent.title END AS inherited_from_title
FROM paliad.projekt_teams pt
LEFT JOIN paliad.projekte parent ON parent.id = pt.projekt_id
WHERE pt.projekt_id = ANY($2::uuid[])
),
ranked AS (
SELECT c.*, ROW_NUMBER() OVER (
PARTITION BY c.user_id
ORDER BY c.inherited ASC, c.created_at ASC
) AS rn FROM candidate c
)
SELECT r.id, r.projekt_id, r.user_id, r.role, r.inherited,
r.added_by, r.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
r.inherited_from_id,
r.inherited_from_title
FROM ranked r
LEFT JOIN paliad.users u ON u.id = r.user_id
WHERE r.rn = 1
ORDER BY r.inherited ASC, r.role, u.display_name`
var rows []models.ProjektTeamMemberWithUser
if err := s.db.SelectContext(ctx, &rows, query, projektID, pq.StringArray(ancestorIDs)); err != nil {
return nil, fmt.Errorf("list effective team: %w", err)
}
return rows, nil
}
// IsEffectiveMember reports whether userID is a direct or inherited member of projektID.
func (s *TeamService) IsEffectiveMember(ctx context.Context, projektID, userID uuid.UUID) (bool, error) {
var ok bool
err := s.db.GetContext(ctx, &ok,
`SELECT EXISTS (
SELECT 1 FROM paliad.projekte p
JOIN paliad.projekt_teams pt
ON pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[])
WHERE p.id = $1 AND pt.user_id = $2
)`, projektID, userID)
if err != nil {
return false, fmt.Errorf("check effective membership: %w", err)
}
return ok, nil
}
// ---------------------------------------------------------------------------
func isValidRole(r string) bool {
switch r {
case RoleLead, RoleAssociate, RolePA, RoleOfCounsel,
RoleLocalCounsel, RoleExpert, RoleObserver:
return true
}
return false
}
// pathToIDStrings splits a materialised path into its UUID labels as strings,
// suitable for pq.StringArray → uuid[] cast.
func pathToIDStrings(path string) []string {
if path == "" {
return nil
}
parts := strings.Split(path, ".")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
out = append(out, p)
}
}
return out
}

View File

@@ -17,22 +17,18 @@ import (
// TerminService reads and writes paliad.termine.
//
// Visibility:
// - akte_id IS NULL → personal Termin, visible/editable only to created_by
// - akte_id IS NOT NULL → follows AkteService.GetByID office-scoped rules
// - projekt_id IS NULL → personal Termin, visible/editable only to created_by
// - projekt_id IS NOT NULL → follows ProjektService.GetByID team gate
//
// Audit: Akte-attached mutations append akten_events rows (matching the
// FristService pattern). Personal Termine never touch akten_events.
// Audit: Projekt-attached mutations append projekt_events rows. Personal
// Termine never touch projekt_events.
//
// CalDAV: optional hook (TerminCalDAVPusher) is called best-effort after
// each mutation. Sync failures don't fail the user-facing request.
// each mutation.
type TerminService struct {
db *sqlx.DB
akten *AkteService
db *sqlx.DB
projekte *ProjektService
// caldav is an optional best-effort push hook. nil during tests or when
// the encryption key isn't configured. Set via SetCalDAVPusher after
// construction to break the import cycle (CalDAVService depends on
// TerminService).
caldav TerminCalDAVPusher
}
@@ -45,23 +41,22 @@ type TerminCalDAVPusher interface {
OnTerminDeleted(ctx context.Context, userID uuid.UUID, t *models.Termin)
}
func NewTerminService(db *sqlx.DB, akten *AkteService) *TerminService {
return &TerminService{db: db, akten: akten}
func NewTerminService(db *sqlx.DB, projekte *ProjektService) *TerminService {
return &TerminService{db: db, projekte: projekte}
}
// SetCalDAVPusher wires an optional CalDAV push hook. Called from main()
// once both services are constructed.
// SetCalDAVPusher wires an optional CalDAV push hook.
func (s *TerminService) SetCalDAVPusher(p TerminCalDAVPusher) {
s.caldav = p
}
const terminColumns = `id, akte_id, title, description, start_at, end_at,
const terminColumns = `id, projekt_id, title, description, start_at, end_at,
location, termin_type, caldav_uid, caldav_etag, created_by,
created_at, updated_at`
// CreateTerminInput is the payload for POST /api/termine.
type CreateTerminInput struct {
AkteID *uuid.UUID `json:"akte_id,omitempty"`
ProjektID *uuid.UUID `json:"projekt_id,omitempty"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
StartAt time.Time `json:"start_at"`
@@ -82,42 +77,36 @@ type UpdateTerminInput struct {
// TerminListFilter narrows ListVisibleForUser results.
type TerminListFilter struct {
AkteID *uuid.UUID
From *time.Time
To *time.Time
Type *string
ProjektID *uuid.UUID
From *time.Time
To *time.Time
Type *string
}
// ListVisibleForUser returns all Termine the user can see (personal +
// Akten-attached they have visibility for), ordered by start_at ascending.
func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter TerminListFilter) ([]models.TerminWithAkte, error) {
user, err := s.akten.users.GetByID(ctx, userID)
// Projekt-attached they have visibility for), ordered by start_at ascending.
func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter TerminListFilter) ([]models.TerminWithProjekt, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.TerminWithAkte{}, nil
return []models.TerminWithProjekt{}, nil
}
visibility := `(
(t.akte_id IS NULL AND t.created_by = :user_id)
OR (t.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = :office
OR :user_id = ANY (a.collaborators)
OR :role = 'admin'
))
(t.projekt_id IS NULL AND t.created_by = :user_id)
OR (t.projekt_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
)`
conds := []string{visibility}
args := map[string]any{
"user_id": userID,
"office": user.Office,
"role": user.Role,
}
if filter.AkteID != nil {
conds = append(conds, `t.akte_id = :akte_id`)
args["akte_id"] = *filter.AkteID
if filter.ProjektID != nil {
conds = append(conds, `t.projekt_id = :projekt_id`)
args["projekt_id"] = *filter.ProjektID
}
if filter.From != nil {
conds = append(conds, `t.start_at >= :from`)
@@ -136,14 +125,14 @@ func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID
}
query := `
SELECT t.id, t.akte_id, t.title, t.description, t.start_at, t.end_at,
SELECT t.id, t.projekt_id, t.title, t.description, t.start_at, t.end_at,
t.location, t.termin_type, t.caldav_uid, t.caldav_etag,
t.created_by, t.created_at, t.updated_at,
a.aktenzeichen AS akte_aktenzeichen,
a.title AS akte_title,
a.owning_office AS akte_office
p.reference AS projekt_reference,
p.title AS projekt_title,
p.type AS projekt_type
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
LEFT JOIN paliad.projekte p ON p.id = t.projekt_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY t.start_at ASC, t.created_at DESC`
@@ -153,25 +142,25 @@ func (s *TerminService) ListVisibleForUser(ctx context.Context, userID uuid.UUID
}
defer stmt.Close()
var rows []models.TerminWithAkte
var rows []models.TerminWithProjekt
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list termine: %w", err)
}
return rows, nil
}
// ListForAkte returns Termine for a specific Akte, visibility-checked.
func (s *TerminService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.Termin, error) {
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
// ListForProjekt returns Termine for a specific Projekt, visibility-checked.
func (s *TerminService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Termin, error) {
if _, err := s.projekte.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var rows []models.Termin
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+terminColumns+`
FROM paliad.termine
WHERE akte_id = $1
ORDER BY start_at ASC, created_at DESC`, akteID); err != nil {
return nil, fmt.Errorf("list termine for akte: %w", err)
WHERE projekt_id = $1
ORDER BY start_at ASC, created_at DESC`, projektID); err != nil {
return nil, fmt.Errorf("list termine for projekt: %w", err)
}
return rows, nil
}
@@ -194,14 +183,13 @@ func (s *TerminService) GetByID(ctx context.Context, userID, terminID uuid.UUID)
return &t, nil
}
// requireMutationRole enforces the partner/admin gate on Akte-linked
// Termin mutations (Update, Delete). The Termin's own creator is also
// allowed — they're the one who added it.
// requireMutationRole enforces the partner/admin gate on Projekt-linked
// Termin mutations. The Termin's own creator is also allowed.
func (s *TerminService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Termin) error {
if t.CreatedBy != nil && *t.CreatedBy == userID {
return nil
}
user, err := s.akten.users.GetByID(ctx, userID)
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return err
}
@@ -209,22 +197,22 @@ func (s *TerminService) requireMutationRole(ctx context.Context, userID uuid.UUI
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
return fmt.Errorf("%w: only partners/admins can modify Termine on an Akte", ErrForbidden)
return fmt.Errorf("%w: only partners/admins can modify Termine on a Projekt", ErrForbidden)
}
return nil
}
// canSee mirrors the SELECT visibility predicate for one in-memory Termin.
func (s *TerminService) canSee(ctx context.Context, userID uuid.UUID, t *models.Termin) bool {
if t.AkteID == nil {
if t.ProjektID == nil {
return t.CreatedBy != nil && *t.CreatedBy == userID
}
_, err := s.akten.GetByID(ctx, userID, *t.AkteID)
_, err := s.projekte.GetByID(ctx, userID, *t.ProjektID)
return err == nil
}
// Create inserts a Termin. If akte_id is set, AkteService visibility is
// enforced and the Akte's audit trail records the new appointment.
// Create inserts a Termin. If projekt_id is set, ProjektService visibility
// is enforced and the Projekt's audit trail records the new appointment.
func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input CreateTerminInput) (*models.Termin, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
@@ -240,8 +228,8 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea
return nil, fmt.Errorf("%w: invalid termin_type %q", ErrInvalidInput, *input.TerminType)
}
if input.AkteID != nil {
if _, err := s.akten.GetByID(ctx, userID, *input.AkteID); err != nil {
if input.ProjektID != nil {
if _, err := s.projekte.GetByID(ctx, userID, *input.ProjektID); err != nil {
return nil, err
}
}
@@ -257,19 +245,19 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.termine
(id, akte_id, title, description, start_at, end_at, location,
(id, projekt_id, title, description, start_at, end_at, location,
termin_type, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`,
id, input.AkteID, title, input.Description, input.StartAt.UTC(),
id, input.ProjektID, title, input.Description, input.StartAt.UTC(),
nullableUTC(input.EndAt), input.Location, input.TerminType, userID, now,
); err != nil {
return nil, fmt.Errorf("insert termin: %w", err)
}
if input.AkteID != nil {
if input.ProjektID != nil {
desc := fmt.Sprintf("Termin \u201E%s\u201C angelegt", title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *input.AkteID, userID, "termin_created", "Termin angelegt", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, *input.ProjektID, userID, "termin_created", "Termin angelegt", descPtr); err != nil {
return nil, err
}
}
@@ -288,18 +276,12 @@ func (s *TerminService) Create(ctx context.Context, userID uuid.UUID, input Crea
}
// Update applies a partial update.
//
// Policy:
// - Personal Termin (akte_id IS NULL) → only the creator may edit.
// - Akte-linked Termin → partner/admin only (or the Termin's creator).
// Mirrors the FristService delete policy so an associate in another
// office can't mutate a hearing on a firm-wide Akte.
func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID, input UpdateTerminInput) (*models.Termin, error) {
current, err := s.GetByID(ctx, userID, terminID)
if err != nil {
return nil, err
}
if current.AkteID == nil {
if current.ProjektID == nil {
if current.CreatedBy == nil || *current.CreatedBy != userID {
return nil, fmt.Errorf("%w: only the creator can edit a personal Termin", ErrForbidden)
}
@@ -360,10 +342,10 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID,
return nil, fmt.Errorf("update termin: %w", err)
}
if current.AkteID != nil {
if current.ProjektID != nil {
desc := fmt.Sprintf("Termin \u201E%s\u201C ge\u00e4ndert", current.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID, "termin_updated", "Termin ge\u00e4ndert", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, "termin_updated", "Termin ge\u00e4ndert", descPtr); err != nil {
return nil, err
}
}
@@ -381,18 +363,12 @@ func (s *TerminService) Update(ctx context.Context, userID, terminID uuid.UUID,
}
// Delete removes a Termin.
//
// Policy:
// - Personal Termin (akte_id IS NULL) → only the creator may delete.
// - Akte-linked Termin → partner/admin only (or the Termin's creator).
// Matches the FristService delete gate so an associate viewing a
// firm-wide Akte can't erase hearings from the calendar.
func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID) error {
current, err := s.GetByID(ctx, userID, terminID)
if err != nil {
return err
}
if current.AkteID == nil {
if current.ProjektID == nil {
if current.CreatedBy == nil || *current.CreatedBy != userID {
return fmt.Errorf("%w: only the creator can delete a personal Termin", ErrForbidden)
}
@@ -410,10 +386,10 @@ func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID)
`DELETE FROM paliad.termine WHERE id = $1`, terminID); err != nil {
return fmt.Errorf("delete termin: %w", err)
}
if current.AkteID != nil {
if current.ProjektID != nil {
desc := fmt.Sprintf("Termin \u201E%s\u201C gel\u00f6scht", current.Title)
descPtr := &desc
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID, "termin_deleted", "Termin gel\u00f6scht", descPtr); err != nil {
if err := insertProjektEvent(ctx, tx, *current.ProjektID, userID, "termin_deleted", "Termin gel\u00f6scht", descPtr); err != nil {
return err
}
}
@@ -426,8 +402,7 @@ func (s *TerminService) Delete(ctx context.Context, userID, terminID uuid.UUID)
return nil
}
// SummaryCounts returns Heute / Diese Woche / Sp\u00e4ter counts for the
// user's visible Termine.
// TerminSummaryCounts buckets visible Termine into today / this_week / later.
type TerminSummaryCounts struct {
Today int `json:"today"`
ThisWeek int `json:"this_week"`
@@ -435,8 +410,9 @@ type TerminSummaryCounts struct {
Total int `json:"total"`
}
// SummaryCounts aggregates Termine by start-date bucket for the user's visible projects.
func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*TerminSummaryCounts, error) {
user, err := s.akten.users.GetByID(ctx, userID)
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
@@ -455,15 +431,10 @@ func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*T
COUNT(*) FILTER (WHERE t.start_at >= :endweek) AS later,
COUNT(*) FILTER (WHERE t.start_at >= :today) AS total
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
LEFT JOIN paliad.projekte p ON p.id = t.projekt_id
WHERE
((t.akte_id IS NULL AND t.created_by = :user_id)
OR (t.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = :office
OR :user_id = ANY (a.collaborators)
OR :role = 'admin'
)))`
(t.projekt_id IS NULL AND t.created_by = :user_id)
OR (t.projekt_id IS NOT NULL AND ` + visibilityPredicate("p") + `)`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
@@ -477,7 +448,6 @@ func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*T
"tomorrow": tomorrow,
"endweek": endOfWeek,
"user_id": userID,
"office": user.Office,
"role": user.Role,
}); err != nil {
return nil, fmt.Errorf("termin summary: %w", err)
@@ -485,9 +455,7 @@ func (s *TerminService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*T
return &c, nil
}
// SetCalDAVMeta is called by the CalDAV service after a successful push so
// future pulls can use the etag for change detection. Bypasses visibility
// (system-internal call).
// SetCalDAVMeta is called by the CalDAV service after a successful push.
func (s *TerminService) SetCalDAVMeta(ctx context.Context, terminID uuid.UUID, uid, etag string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.termine
@@ -499,11 +467,10 @@ func (s *TerminService) SetCalDAVMeta(ctx context.Context, terminID uuid.UUID, u
return nil
}
// AllForUser returns every Termin (personal + visible Akten-attached) the
// user owns. Used by the CalDAV push loop, which needs the full set to
// reconcile.
// AllForUser returns every Termin (personal + visible Projekt-attached) the
// user owns. Used by the CalDAV push loop.
func (s *TerminService) AllForUser(ctx context.Context, userID uuid.UUID) ([]models.Termin, error) {
user, err := s.akten.users.GetByID(ctx, userID)
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
@@ -514,23 +481,21 @@ func (s *TerminService) AllForUser(ctx context.Context, userID uuid.UUID) ([]mod
query := `
SELECT ` + terminColumns + `
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
LEFT JOIN paliad.projekte p ON p.id = t.projekt_id
WHERE
(t.akte_id IS NULL AND t.created_by = $1)
OR (t.akte_id IS NOT NULL AND (
a.firm_wide_visible = true
OR a.owning_office = $2
OR $1 = ANY (a.collaborators)
OR $3 = 'admin'
))`
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Office, user.Role); err != nil {
(t.projekt_id IS NULL AND t.created_by = $1)
OR (t.projekt_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
SELECT 1 FROM paliad.projekt_teams pt
WHERE pt.user_id = $1
AND pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[])
)))`
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Role); err != nil {
return nil, fmt.Errorf("all termine for user: %w", err)
}
return rows, nil
}
// FindByCalDAVUID resolves a Termin from its external UID (used during pull
// reconciliation). Returns ErrNotVisible if not found.
// FindByCalDAVUID resolves a Termin from its external UID.
func (s *TerminService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Termin, error) {
var t models.Termin
err := s.db.GetContext(ctx, &t,
@@ -544,9 +509,7 @@ func (s *TerminService) FindByCalDAVUID(ctx context.Context, uid string) (*model
return &t, nil
}
// ApplyRemoteUpdate is the inverse of SetCalDAVMeta: writes pulled changes
// into the local row. Caller is the CalDAV service. Returns true if any
// columns actually changed, used by the conflict-logger.
// ApplyRemoteUpdate writes pulled CalDAV changes into the local row.
func (s *TerminService) ApplyRemoteUpdate(ctx context.Context, terminID uuid.UUID, title, description, location *string, startAt, endAt *time.Time, etag string) (bool, error) {
sets := []string{"caldav_etag = $1", "updated_at = NOW()"}
args := []any{etag}
@@ -592,7 +555,6 @@ func (s *TerminService) ApplyRemoteUpdate(ctx context.Context, terminID uuid.UUI
}
// DeleteByCalDAVUID removes a Termin pulled-deleted from the remote calendar.
// Bypasses visibility; the caller (CalDAV service) is system-internal.
func (s *TerminService) DeleteByCalDAVUID(ctx context.Context, uid string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.termine WHERE caldav_uid = $1`, uid)
@@ -602,33 +564,37 @@ func (s *TerminService) DeleteByCalDAVUID(ctx context.Context, uid string) error
return nil
}
// LogConflict appends a conflict event to the parent Akte's audit trail.
// No-op for personal Termine. Attribution falls back to the Termin's
// created_by when the caller is system-internal (CalDAV sync loop).
// LogConflict appends a conflict event to the parent Projekt's audit trail.
// No-op for personal Termine.
func (s *TerminService) LogConflict(ctx context.Context, terminID uuid.UUID, msg string) error {
var row struct {
AkteID *uuid.UUID `db:"akte_id"`
ProjektID *uuid.UUID `db:"projekt_id"`
CreatedBy *uuid.UUID `db:"created_by"`
}
err := s.db.GetContext(ctx, &row,
`SELECT akte_id, created_by FROM paliad.termine WHERE id = $1`, terminID)
if err != nil || row.AkteID == nil {
return nil //nolint:nilerr // intentional: skip if missing or personal
`SELECT projekt_id, created_by FROM paliad.termine WHERE id = $1`, terminID)
if err != nil || row.ProjektID == nil {
return nil //nolint:nilerr
}
now := time.Now().UTC()
desc := msg
_, err = s.db.ExecContext(ctx,
`INSERT INTO paliad.akten_events
(id, akte_id, event_type, title, description, event_date,
`INSERT INTO paliad.projekt_events
(id, projekt_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, 'caldav_conflict', 'CalDAV conflict', $3, $4, $5, '{}', $4, $4)`,
uuid.New(), *row.AkteID, desc, now, row.CreatedBy)
uuid.New(), *row.ProjektID, desc, now, row.CreatedBy)
if err != nil {
return fmt.Errorf("insert caldav conflict event: %w", err)
}
return nil
}
// users returns the shared user service via the Projekt handle.
func (s *TerminService) users() *UserService {
return s.projekte.Users()
}
func nullableUTC(t *time.Time) any {
if t == nil {
return nil