diff --git a/internal/models/models.go b/internal/models/models.go index 7596e82..0df7e3d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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 " () — " 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 " 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 " 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"` diff --git a/internal/services/akte_service.go b/internal/services/akte_service.go deleted file mode 100644 index 2d0ee27..0000000 --- a/internal/services/akte_service.go +++ /dev/null @@ -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 -} - diff --git a/internal/services/checklist_instance_service.go b/internal/services/checklist_instance_service.go index 459fae2..d208aef 100644 --- a/internal/services/checklist_instance_service.go +++ b/internal/services/checklist_instance_service.go @@ -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 } diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index bdfa963..ac5a80a 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -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 { diff --git a/internal/services/dezernat_service.go b/internal/services/dezernat_service.go new file mode 100644 index 0000000..d56eb4a --- /dev/null +++ b/internal/services/dezernat_service.go @@ -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: ". +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 +} diff --git a/internal/services/frist_service.go b/internal/services/frist_service.go index 9dacd9f..ba5c7c1 100644 --- a/internal/services/frist_service.go +++ b/internal/services/frist_service.go @@ -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 { diff --git a/internal/services/notiz_service.go b/internal/services/notiz_service.go index 0c5cad2..f0be611 100644 --- a/internal/services/notiz_service.go +++ b/internal/services/notiz_service.go @@ -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) { diff --git a/internal/services/parteien_service.go b/internal/services/parteien_service.go index 3337eb6..3a261d8 100644 --- a/internal/services/parteien_service.go +++ b/internal/services/parteien_service.go @@ -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, diff --git a/internal/services/projekt_service.go b/internal/services/projekt_service.go new file mode 100644 index 0000000..6814582 --- /dev/null +++ b/internal/services/projekt_service.go @@ -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] + } + } +} diff --git a/internal/services/reminder_service.go b/internal/services/reminder_service.go index 134f5f1..a5f59b6 100644 --- a/internal/services/reminder_service.go +++ b/internal/services/reminder_service.go @@ -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 diff --git a/internal/services/team_service.go b/internal/services/team_service.go new file mode 100644 index 0000000..92246d4 --- /dev/null +++ b/internal/services/team_service.go @@ -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 +} diff --git a/internal/services/termin_service.go b/internal/services/termin_service.go index 812b4a2..8fb2667 100644 --- a/internal/services/termin_service.go +++ b/internal/services/termin_service.go @@ -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