Files
paliad/internal/models/models.go
m 9aa8037193 refactor: services — Projekt, Team, Dezernat services (WIP Phase 2)
Models: Akte → Projekt (tree type + parent_id + path + client/matter numbers
+ netDocuments URL + type-specific client/patent/case columns). AkteEvent →
ProjektEvent. FristWithAkte → FristWithProjekt. TerminWithAkte → TerminWithProjekt.
Notiz.AkteID → ProjektID. ChecklistInstance.AkteID → ProjektID. Partei.AkteID →
ProjektID. User adds AdditionalOffices pq.StringArray.

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

Handlers/tests still reference old API — Phase 2 completion requires handler
rewrite (next commit). Build currently broken in internal/handlers.
2026-04-20 14:46:59 +02:00

333 lines
18 KiB
Go

// Package models holds the database row types for paliad.* tables.
// Names mirror the German schema (Projekt, Frist, Termin, Notiz, …).
// See internal/db/migrations/ for the canonical schema definitions.
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
// 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 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"`
// 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"`
}
// 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"`
}
// ProjektTeamMember is one row of paliad.projekt_teams — direct membership
// only. Inherited memberships are computed at read time by walking the path;
// services set Inherited=true on the in-memory copy when annotating a list
// result that mixes direct + inherited rows.
type ProjektTeamMember struct {
ID uuid.UUID `db:"id" json:"id"`
ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Role string `db:"role" json:"role"`
Inherited bool `db:"inherited" json:"inherited"`
AddedBy *uuid.UUID `db:"added_by" json:"added_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ProjektTeamMemberWithUser enriches a team row with display fields so the
// UI can render "<DisplayName> (<Email>) — <Role>" without a per-row lookup.
// Used by TeamService.ListMembers which unions direct + inherited memberships.
type ProjektTeamMemberWithUser struct {
ProjektTeamMember
UserEmail string `db:"user_email" json:"user_email"`
UserDisplayName string `db:"user_display_name" json:"user_display_name"`
UserOffice string `db:"user_office" json:"user_office"`
// InheritedFromID is the ancestor projekt_id the membership came from
// when Inherited=true. NULL for direct rows.
InheritedFromID *uuid.UUID `db:"inherited_from_id" json:"inherited_from_id,omitempty"`
InheritedFromTitle *string `db:"inherited_from_title" json:"inherited_from_title,omitempty"`
}
// Dezernat is one structural partner unit. Dezernat membership is orthogonal
// to project teams — a user typically belongs to exactly one Dezernat but
// may work on projects across all of them.
type Dezernat struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
LeadUserID *uuid.UUID `db:"lead_user_id" json:"lead_user_id,omitempty"`
Office string `db:"office" json:"office"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// DezernatMitglied is one user's membership in a Dezernat.
type DezernatMitglied struct {
DezernatID uuid.UUID `db:"dezernat_id" json:"dezernat_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ProjektEvent is one row in the per-Projekt audit trail (paliad.projekt_events,
// renamed from paliad.akten_events in migration 018).
type ProjektEvent struct {
ID uuid.UUID `db:"id" json:"id"`
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"`
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// 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"`
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"`
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
Source string `db:"source" json:"source"`
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
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"`
}
// FristWithProjekt enriches a Frist with parent-Projekt display fields
// (reference + title) for list views.
type FristWithProjekt struct {
Frist
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. 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"`
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"`
EndAt *time.Time `db:"end_at" json:"end_at,omitempty"`
Location *string `db:"location" json:"location,omitempty"`
TerminType *string `db:"termin_type" json:"termin_type,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
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"`
}
// 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
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
// (Projekt, Frist, Termin, or ProjektEvent). Visibility follows the parent.
type Notiz struct {
ID uuid.UUID `db:"id" json:"id"`
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 UI can render "von <Name>" without a lookup.
AuthorName *string `db:"author_name" json:"author_name,omitempty"`
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
}
// ChecklistInstance is one user's instantiation of a static checklist
// template (defined in internal/checklisten). Checkbox state lives in the
// `state` jsonb column.
//
// 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"`
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"`
}
// ChecklistInstanceWithProjekt enriches an instance with its parent Projekt
// reference fields for list views.
type ChecklistInstanceWithProjekt struct {
ChecklistInstance
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 are exposed.
type UserCalDAVConfig struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
URL string `db:"url" json:"url"`
Username string `db:"username" json:"username"`
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
CalendarPath string `db:"calendar_path" json:"calendar_path"`
Enabled bool `db:"enabled" json:"enabled"`
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// 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"`
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
Direction string `db:"direction" json:"direction"`
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
Error *string `db:"error" json:"error,omitempty"`
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
}
// 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"`
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"`
ContactInfo json.RawMessage `db:"contact_info" json:"contact_info"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
DurationValue int `db:"duration_value" json:"duration_value"`
DurationUnit string `db:"duration_unit" json:"duration_unit"`
Timing *string `db:"timing" json:"timing,omitempty"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
// management) or UPC_*/DE_*/EPA_*/EP_GRANT (Fristenrechner UI).
type ProceedingType struct {
ID int `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
Category *string `db:"category" json:"category,omitempty"`
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
}