Files
paliad/internal/models/models.go
m 7c44bbec7e refactor: onboarding form — drop Praxisgruppe, free-text role, add Dezernat (t-paliad-020)
- Drop the Praxisgruppe field from the onboarding form. Every Paliad user
  is in patent practice, so the field carried no signal. The DB column is
  retained for future use (set to NULL on insert).
- Switch role from a 4-value enum (partner/associate/pa/admin) to free
  text with a <datalist> of suggestions (Partner, Associate, PA, Of
  Counsel, Referendar/in, Trainee, wiss. Mitarbeiter/in, Sekretariat).
  German firms have many roles beyond the original four.
- Add an optional Dezernat field — the team led by a specific partner.
  Free text, no FK (the partner may not be registered yet).

Backend:
- Migration 015: drop the role enum CHECK, replace with non-empty CHECK;
  ADD COLUMN dezernat text.
- UserService.Create: drop validRoles map, require non-empty role string,
  trim and persist Dezernat. Admin bootstrap gate unchanged.
- models.User gains Dezernat *string; userColumns SELECT updated so
  /api/me returns it.

Frontend:
- onboarding.tsx: replace role <select> with <input list=...>; add
  dezernat input; remove practice_group.
- onboarding.ts: send dezernat (if non-empty), require role.
- i18n: add onboarding.role.placeholder, onboarding.dezernat[.placeholder],
  onboarding.error.role; remove the role.* enum and practice_group keys.
2026-04-18 20:26:11 +02:00

257 lines
14 KiB
Go

// Package models holds the database row types for paliad.* tables.
// Names mirror the German schema (Akte, 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 Akten.
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"`
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"`
}
// AkteEvent is one row in the per-Akte audit trail.
type AkteEvent struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_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 an Akte.
// Visibility is inherited from the parent Akte (see paliad.can_see_akte).
type Frist struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_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"`
}
// 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 {
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"`
}
// 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.
type Termin struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID *uuid.UUID `db:"akte_id" json:"akte_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"`
}
// 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 {
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"`
}
// 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.
type Notiz struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID *uuid.UUID `db:"akte_id" json:"akte_id,omitempty"`
FristID *uuid.UUID `db:"frist_id" json:"frist_id,omitempty"`
TerminID *uuid.UUID `db:"termin_id" json:"termin_id,omitempty"`
AktenEventID *uuid.UUID `db:"akten_event_id" json:"akten_event_id,omitempty"`
Content string `db:"content" json:"content"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// Author display fields, populated by the service's LEFT JOIN to
// paliad.users so the client can render "von <Name>" without a
// per-row /api/users lookup.
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}.
//
// 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.
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"`
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 {
ChecklistInstance
AkteAktenzeichen *string `db:"akte_aktenzeichen" json:"akte_aktenzeichen,omitempty"`
AkteTitle *string `db:"akte_title" json:"akte_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.
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 (last 5 retained per user).
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 an Akte (Kläger, Beklagter, etc.).
type Partei struct {
ID uuid.UUID `db:"id" json:"id"`
AkteID uuid.UUID `db:"akte_id" json:"akte_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"`
}