Files
paliad/internal/models/models.go
m 5a9f8e5874 feat(notizen): Phase I — Notizen (polymorphic notes)
Polymorphic notes attached to Akten, Fristen, Termine, or AktenEvents.
Schema (paliad.notizen + paliad.notiz_is_visible) shipped with Phase A
migrations; this phase adds the service, handlers, and shared UI.

Backend
- NotizService (internal/services/notiz_service.go): ListForAkte /
  ListForFrist / ListForTermin / ListForAktenEvent + Create / Update /
  Delete. Visibility resolves through the parent row — AkteService.GetByID
  for Akte/Frist/AktenEvent parents, TerminService.GetByID for Termin
  parents (personal Termine are creator-only).
- Edit restricted to the original author; delete allows author +
  partner/admin. Create on an Akte-scoped parent appends an akten_events
  "notiz_created" audit row in the same transaction; personal Termin
  notes skip the audit.
- Author join (paliad.users) surfaces display_name + email on every
  listed note so the client can render "von <Name>" without per-row
  /api/users fetches.
- Routes wired in handlers.go: GET/POST /api/akten|fristen|termine/{id}/
  notizen, PATCH/DELETE /api/notizen/{id}.

Frontend
- Shared client module frontend/src/client/notizen.ts exposes
  initNotes(container, parentType, parentId). Renders an add-note form,
  list of note cards with relative timestamps (gerade eben / vor N
  Minuten / gestern / …), edit + delete affordances gated by author/
  role, optimistic add/edit/delete with rollback on error, Ctrl+Enter
  submit, and URL auto-linkification inside sanitised note bodies.
- Integrated into akten-detail (Notizen tab — placeholder replaced),
  fristen-detail (new "Notizen" section below the detail list), and
  termine-detail (new "Notizen" section above the edit form).
- DE + EN i18n keys added; obsolete akten.detail.soon.notizen placeholder
  keys removed.
- Notiz-card styles added to global.css (accent-coloured focus, hover
  actions, relative-time colour) matching the existing Verlauf card look.
2026-04-17 12:12:29 +02:00

229 lines
13 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"`
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"`
}
// 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"`
}