refactor: services — Projekt, Team, Dezernat services (WIP Phase 2)
Models: Akte → Projekt (tree type + parent_id + path + client/matter numbers + netDocuments URL + type-specific client/patent/case columns). AkteEvent → ProjektEvent. FristWithAkte → FristWithProjekt. TerminWithAkte → TerminWithProjekt. Notiz.AkteID → ProjektID. ChecklistInstance.AkteID → ProjektID. Partei.AkteID → ProjektID. User adds AdditionalOffices pq.StringArray. Services: - NEW projekt_service.go replaces akte_service.go. Adds tree ops: List/GetByID/ ListChildren/ListAncestors/GetTree. Create auto-adds creator to projekt_teams role=lead in same tx. ResolveClientNumber walks path for inheritance. Visibility helpers (visibilityPredicate / Positional / Placeholder) centralise team-based access check: admin OR any ancestor/direct projekt_teams row. - NEW team_service.go — AddMember/RemoveMember/ListDirectMembers/ ListEffectiveMembers (unions direct + inherited via path, dedup by user; direct wins)/IsEffectiveMember. Inherited=true set at read time only. - NEW dezernat_service.go — admin-gated CRUD + member add/remove + user membership lookup for settings page. - frist_service.go → projekt_id everywhere, uses visibilityPredicate. ListFilter. AkteID → ProjektID. - termin_service.go → projekt_id everywhere. CalDAV log reads projekt_events. - notiz_service.go → projekt_id polymorphic branch; eventProjektID() looks at projekt_events; akten_event_id column kept (FK now resolves to projekt_events). - parteien_service.go → projekt_id. - checklist_instance_service.go → projekt_id with ClearProjekt flag. - dashboard_service.go → rewrites all four queries against projekte + projekt_events + projekt_teams. Matter/Upcoming/Activity surfaces use ProjektID/ProjektTitle/ProjektRef. - reminder_service.go → joins paliad.projekte, aliases a.reference AS akte_aktenzeichen for template compat. Handlers/tests still reference old API — Phase 2 completion requires handler rewrite (next commit). Build currently broken in internal/handlers.
This commit is contained in:
@@ -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"`
|
||||
// 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 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"`
|
||||
}
|
||||
|
||||
// Akte is a matter (case file). Office-scoped visibility: see paliad.can_see_akte.
|
||||
type Akte struct {
|
||||
// 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"`
|
||||
Aktenzeichen string `db:"aktenzeichen" json:"aktenzeichen"`
|
||||
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"`
|
||||
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"`
|
||||
Reference *string `db:"reference" json:"reference,omitempty"`
|
||||
Description *string `db:"description" json:"description,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"`
|
||||
|
||||
// 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"`
|
||||
AkteID uuid.UUID `db:"akte_id" json:"akte_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"`
|
||||
@@ -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"`
|
||||
ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"`
|
||||
ProjektTitle string `db:"projekt_title" json:"projekt_title"`
|
||||
ProjektType string `db:"projekt_type" json:"projekt_type"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
}
|
||||
|
||||
// Termin is one appointment. akte_id is nullable: when NULL the Termin is
|
||||
// personal (visible only to the creator); when set it follows the parent
|
||||
// Akte's office-scoped visibility.
|
||||
// Termin is one appointment. projekt_id is nullable: NULL = personal
|
||||
// (creator-only); set = follows the parent Projekt's team visibility.
|
||||
type Termin struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
AkteID *uuid.UUID `db:"akte_id" json:"akte_id,omitempty"`
|
||||
ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
StartAt time.Time `db:"start_at" json:"start_at"`
|
||||
@@ -121,70 +190,65 @@ type Termin struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TerminWithAkte enriches a Termin with parent Akte fields for the list
|
||||
// view, mirroring FristWithAkte. Akte fields are nullable because personal
|
||||
// Termine have no parent Akte.
|
||||
type TerminWithAkte struct {
|
||||
// TerminWithProjekt enriches a Termin with its parent Projekt display
|
||||
// fields for list views. All fields nullable because personal Termine have
|
||||
// no parent.
|
||||
type TerminWithProjekt struct {
|
||||
Termin
|
||||
AkteAktenzeichen *string `db:"akte_aktenzeichen" json:"akte_aktenzeichen,omitempty"`
|
||||
AkteTitle *string `db:"akte_title" json:"akte_title,omitempty"`
|
||||
AkteOffice *string `db:"akte_office" json:"akte_office,omitempty"`
|
||||
ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"`
|
||||
ProjektTitle *string `db:"projekt_title" json:"projekt_title,omitempty"`
|
||||
ProjektType *string `db:"projekt_type" json:"projekt_type,omitempty"`
|
||||
}
|
||||
|
||||
// Notiz is one polymorphic note attached to exactly one parent row
|
||||
// (Akte, Frist, Termin, or AktenEvent). Visibility is inherited from
|
||||
// whichever parent FK is set — see paliad.notiz_is_visible.
|
||||
// (Projekt, Frist, Termin, or ProjektEvent). Visibility follows the parent.
|
||||
type Notiz struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
AkteID *uuid.UUID `db:"akte_id" json:"akte_id,omitempty"`
|
||||
ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"`
|
||||
FristID *uuid.UUID `db:"frist_id" json:"frist_id,omitempty"`
|
||||
TerminID *uuid.UUID `db:"termin_id" json:"termin_id,omitempty"`
|
||||
// AktenEventID column name was kept for continuity with the v1 schema;
|
||||
// the FK now resolves to paliad.projekt_events (renamed in 018).
|
||||
AktenEventID *uuid.UUID `db:"akten_event_id" json:"akten_event_id,omitempty"`
|
||||
Content string `db:"content" json:"content"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Author display fields, populated by the service's LEFT JOIN to
|
||||
// paliad.users so the client can render "von <Name>" without a
|
||||
// per-row /api/users lookup.
|
||||
// Author display fields populated by the service's LEFT JOIN to
|
||||
// paliad.users so the UI can render "von <Name>" without a lookup.
|
||||
AuthorName *string `db:"author_name" json:"author_name,omitempty"`
|
||||
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
|
||||
}
|
||||
|
||||
// ChecklistInstance is one user's instantiation of a static checklist
|
||||
// template (defined in internal/checklisten). Checkbox state lives in
|
||||
// the `state` jsonb column as {"g{groupIdx}-i{itemIdx}": true}.
|
||||
// template (defined in internal/checklisten). Checkbox state lives in the
|
||||
// `state` jsonb column.
|
||||
//
|
||||
// Visibility mirrors Termin: akte_id nullable. Personal instances
|
||||
// (akte_id NULL) are creator-only; Akte-linked instances follow the
|
||||
// office-scoped paliad.can_see_akte gate.
|
||||
// Visibility mirrors Termin: projekt_id nullable. Personal instances
|
||||
// (projekt_id NULL) are creator-only; Projekt-linked instances follow
|
||||
// paliad.can_see_projekt.
|
||||
type ChecklistInstance struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
TemplateSlug string `db:"template_slug" json:"template_slug"`
|
||||
Name string `db:"name" json:"name"`
|
||||
AkteID *uuid.UUID `db:"akte_id" json:"akte_id,omitempty"`
|
||||
ProjektID *uuid.UUID `db:"projekt_id" json:"projekt_id,omitempty"`
|
||||
State json.RawMessage `db:"state" json:"state"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ChecklistInstanceWithAkte enriches an instance with its parent Akte
|
||||
// reference fields for list views (so the template page can show
|
||||
// "Case A — Aktenzeichen 2026/0042" without a per-row lookup).
|
||||
type ChecklistInstanceWithAkte struct {
|
||||
// ChecklistInstanceWithProjekt enriches an instance with its parent Projekt
|
||||
// reference fields for list views.
|
||||
type ChecklistInstanceWithProjekt struct {
|
||||
ChecklistInstance
|
||||
AkteAktenzeichen *string `db:"akte_aktenzeichen" json:"akte_aktenzeichen,omitempty"`
|
||||
AkteTitle *string `db:"akte_title" json:"akte_title,omitempty"`
|
||||
ProjektReference *string `db:"projekt_reference" json:"projekt_reference,omitempty"`
|
||||
ProjektTitle *string `db:"projekt_title" json:"projekt_title,omitempty"`
|
||||
}
|
||||
|
||||
// UserCalDAVConfig holds one user's external CalDAV connection. The
|
||||
// password is never returned in API responses; only the public fields
|
||||
// (URL, username, calendar path, enabled, last_sync_at) are exposed.
|
||||
//
|
||||
// password_encrypted is AES-GCM ciphertext with a 12-byte random nonce
|
||||
// prepended. Decrypted only inside services.CalDAVService.
|
||||
// UserCalDAVConfig holds one user's external CalDAV connection. The password
|
||||
// is never returned in API responses; only the public fields are exposed.
|
||||
type UserCalDAVConfig struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
URL string `db:"url" json:"url"`
|
||||
@@ -198,7 +262,7 @@ type UserCalDAVConfig struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// CalDAVSyncLogEntry is one historical sync record (last 5 retained per user).
|
||||
// CalDAVSyncLogEntry is one historical sync record.
|
||||
type CalDAVSyncLogEntry struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
@@ -210,10 +274,11 @@ type CalDAVSyncLogEntry struct {
|
||||
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// Partei is a party to an Akte (Kläger, Beklagter, etc.).
|
||||
// Partei is a party to a Projekt (Kläger, Beklagter, etc. — typically on
|
||||
// a case-level projekt).
|
||||
type Partei struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
AkteID uuid.UUID `db:"akte_id" json:"akte_id"`
|
||||
ProjektID uuid.UUID `db:"projekt_id" json:"projekt_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Role *string `db:"role" json:"role,omitempty"`
|
||||
Representative *string `db:"representative" json:"representative,omitempty"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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"`
|
||||
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"`
|
||||
ProjektID *uuid.UUID `json:"projekt_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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,35 +53,35 @@ 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"`
|
||||
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"`
|
||||
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.
|
||||
@@ -94,19 +89,15 @@ 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"`
|
||||
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
|
||||
}
|
||||
@@ -205,93 +200,88 @@ func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *Dash
|
||||
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
|
||||
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 {
|
||||
|
||||
233
internal/services/dezernat_service.go
Normal file
233
internal/services/dezernat_service.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package services
|
||||
|
||||
// DezernatService handles paliad.dezernate + paliad.dezernat_mitglieder —
|
||||
// the structural partner-led units. Orthogonal to project teams.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
"mgit.msbls.de/m/patholo/internal/offices"
|
||||
)
|
||||
|
||||
// DezernatService reads and writes paliad.dezernate.
|
||||
type DezernatService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewDezernatService wires the service.
|
||||
func NewDezernatService(db *sqlx.DB, users *UserService) *DezernatService {
|
||||
return &DezernatService{db: db, users: users}
|
||||
}
|
||||
|
||||
// CreateDezernatInput is the payload for Create.
|
||||
type CreateDezernatInput struct {
|
||||
Name string `json:"name"`
|
||||
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
||||
Office string `json:"office"`
|
||||
}
|
||||
|
||||
// UpdateDezernatInput is the partial-update payload.
|
||||
type UpdateDezernatInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
||||
Office *string `json:"office,omitempty"`
|
||||
}
|
||||
|
||||
// List returns every Dezernat (readable by any authenticated user — see RLS).
|
||||
func (s *DezernatService) List(ctx context.Context) ([]models.Dezernat, error) {
|
||||
var rows []models.Dezernat
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
||||
FROM paliad.dezernate
|
||||
ORDER BY office, name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dezernate: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID returns one Dezernat or (nil, sql.ErrNoRows).
|
||||
func (s *DezernatService) GetByID(ctx context.Context, id uuid.UUID) (*models.Dezernat, error) {
|
||||
var d models.Dezernat
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
||||
FROM paliad.dezernate WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get dezernat: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Create inserts a Dezernat. Admin-only.
|
||||
func (s *DezernatService) Create(ctx context.Context, callerID uuid.UUID, input CreateDezernatInput) (*models.Dezernat, error) {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if !offices.IsValid(input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
|
||||
}
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.dezernate (id, name, lead_user_id, office, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)`,
|
||||
id, input.Name, input.LeadUserID, input.Office, now); err != nil {
|
||||
return nil, fmt.Errorf("insert dezernat: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Update applies a partial update. Admin-only.
|
||||
func (s *DezernatService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdateDezernatInput) (*models.Dezernat, error) {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
next := 1
|
||||
appendSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
if input.Name != nil {
|
||||
appendSet("name", *input.Name)
|
||||
}
|
||||
if input.LeadUserID != nil {
|
||||
appendSet("lead_user_id", *input.LeadUserID)
|
||||
}
|
||||
if input.Office != nil {
|
||||
if !offices.IsValid(*input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office)
|
||||
}
|
||||
appendSet("office", *input.Office)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE paliad.dezernate SET %s WHERE id = $%d",
|
||||
strings.Join(sets, ", "), next)
|
||||
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update dezernat: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a Dezernat (cascades memberships). Admin-only.
|
||||
func (s *DezernatService) Delete(ctx context.Context, callerID, id uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM paliad.dezernate WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete dezernat: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMember inserts a (dezernat, user) membership. Admin-only. Idempotent.
|
||||
func (s *DezernatService) AddMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.dezernat_mitglieder (dezernat_id, user_id, created_at)
|
||||
VALUES ($1, $2, now()) ON CONFLICT (dezernat_id, user_id) DO NOTHING`,
|
||||
dezernatID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add dezernat member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMember deletes a (dezernat, user) membership. Admin-only.
|
||||
func (s *DezernatService) RemoveMember(ctx context.Context, callerID, dezernatID, userID uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.dezernat_mitglieder WHERE dezernat_id = $1 AND user_id = $2`,
|
||||
dezernatID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove dezernat member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMembers returns users in the Dezernat, enriched with display fields.
|
||||
type DezernatMember struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Office string `db:"office" json:"office"`
|
||||
Role string `db:"role" json:"role"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// ListMembers returns users in the Dezernat (readable by any authenticated user).
|
||||
func (s *DezernatService) ListMembers(ctx context.Context, dezernatID uuid.UUID) ([]DezernatMember, error) {
|
||||
var rows []DezernatMember
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT dm.user_id, dm.created_at,
|
||||
u.email, u.display_name, u.office, u.role
|
||||
FROM paliad.dezernat_mitglieder dm
|
||||
LEFT JOIN paliad.users u ON u.id = dm.user_id
|
||||
WHERE dm.dezernat_id = $1
|
||||
ORDER BY u.display_name`, dezernatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dezernat members: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetMembership returns the user's Dezernat memberships (zero or more).
|
||||
// Used by the settings page to render "Your Dezernat: <name>".
|
||||
func (s *DezernatService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Dezernat, error) {
|
||||
var rows []models.Dezernat
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at
|
||||
FROM paliad.dezernate d
|
||||
JOIN paliad.dezernat_mitglieder dm ON dm.dezernat_id = d.id
|
||||
WHERE dm.user_id = $1
|
||||
ORDER BY d.name`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user dezernat memberships: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *DezernatService) requireAdmin(ctx context.Context, userID uuid.UUID) error {
|
||||
u, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u == nil || u.Role != "admin" {
|
||||
return fmt.Errorf("%w: admin required", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
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`
|
||||
|
||||
@@ -70,36 +69,28 @@ const (
|
||||
// ListFilter narrows ListVisibleForUser results.
|
||||
type ListFilter struct {
|
||||
Status FristStatusFilter
|
||||
AkteID *uuid.UUID
|
||||
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,
|
||||
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,22 +404,16 @@ 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 := `
|
||||
@@ -441,7 +424,7 @@ func (s *FristService) SummaryCounts(ctx context.Context, userID uuid.UUID, akte
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
|
||||
693
internal/services/projekt_service.go
Normal file
693
internal/services/projekt_service.go
Normal file
@@ -0,0 +1,693 @@
|
||||
package services
|
||||
|
||||
// ProjektService handles CRUD on paliad.projekte — the hierarchical
|
||||
// project tree that replaced the flat paliad.akten model in migration 018.
|
||||
//
|
||||
// Visibility (design v2, adjusted 2026-04-20): team-based only.
|
||||
// A user can see a Projekt iff
|
||||
// - user is admin, or
|
||||
// - user is a direct member of the Projekt's team, or
|
||||
// - user is a member of any ancestor Projekt's team (inherited via path).
|
||||
//
|
||||
// Office is no longer a visibility gate. Cases associate with lead partners,
|
||||
// not offices (see paliad.projekt_teams role='lead').
|
||||
//
|
||||
// The canonical predicate lives in SQL (paliad.can_see_projekt) and is
|
||||
// enforced by RLS policies. This service re-implements the same predicate
|
||||
// at the application layer so the service-role DB connection (without an
|
||||
// auth.uid() JWT) still gates correctly.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
)
|
||||
|
||||
// Sentinel errors.
|
||||
var (
|
||||
// ErrNotVisible indicates the Projekt exists but the user has no
|
||||
// visibility. Handlers must map to 404 (never leak existence).
|
||||
ErrNotVisible = errors.New("projekt not visible")
|
||||
// ErrForbidden indicates the user is authenticated but lacks the role
|
||||
// required for the operation (e.g., associate trying to delete).
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
// ErrInvalidInput signals a bad request (empty required field etc.).
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
// ProjektType values enumerated on the projekte.type CHECK constraint.
|
||||
const (
|
||||
ProjektTypeClient = "client"
|
||||
ProjektTypeLitigation = "litigation"
|
||||
ProjektTypePatent = "patent"
|
||||
ProjektTypeCase = "case"
|
||||
ProjektTypeProject = "project"
|
||||
)
|
||||
|
||||
// ProjektRole values allowed on projekt_teams.role.
|
||||
const (
|
||||
RoleLead = "lead"
|
||||
RoleAssociate = "associate"
|
||||
RolePA = "pa"
|
||||
RoleOfCounsel = "of_counsel"
|
||||
RoleLocalCounsel = "local_counsel"
|
||||
RoleExpert = "expert"
|
||||
RoleObserver = "observer"
|
||||
)
|
||||
|
||||
// ProjektService reads and writes paliad.projekte + paliad.projekt_events.
|
||||
type ProjektService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewProjektService wires the service.
|
||||
func NewProjektService(db *sqlx.DB, users *UserService) *ProjektService {
|
||||
return &ProjektService{db: db, users: users}
|
||||
}
|
||||
|
||||
// Users exposes the shared user service for downstream services that gate
|
||||
// through ProjektService (FristService, TerminService, NotizService, …).
|
||||
func (s *ProjektService) Users() *UserService { return s.users }
|
||||
|
||||
// DB exposes the underlying connection pool for services that need to issue
|
||||
// custom queries (dashboard aggregates, caldav sync). Read-only usage.
|
||||
func (s *ProjektService) DB() *sqlx.DB { return s.db }
|
||||
|
||||
const projektColumns = `id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number, matter_number,
|
||||
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
|
||||
proceeding_type_id, metadata, ai_summary, created_at, updated_at`
|
||||
|
||||
// CreateProjektInput is the payload for Create.
|
||||
type CreateProjektInput struct {
|
||||
Type string `json:"type"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status,omitempty"` // default "active"
|
||||
|
||||
// Type-specific; service applies only the subset matching Type.
|
||||
Industry *string `json:"industry,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
BillingReference *string `json:"billing_reference,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
MatterNumber *string `json:"matter_number,omitempty"`
|
||||
NetDocumentsURL *string `json:"netdocuments_url,omitempty"`
|
||||
PatentNumber *string `json:"patent_number,omitempty"`
|
||||
FilingDate *time.Time `json:"filing_date,omitempty"`
|
||||
GrantDate *time.Time `json:"grant_date,omitempty"`
|
||||
Court *string `json:"court,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProjektInput is the partial-update payload.
|
||||
type UpdateProjektInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"` // reparent; server recomputes path
|
||||
|
||||
Industry *string `json:"industry,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
BillingReference *string `json:"billing_reference,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
MatterNumber *string `json:"matter_number,omitempty"`
|
||||
NetDocumentsURL *string `json:"netdocuments_url,omitempty"`
|
||||
PatentNumber *string `json:"patent_number,omitempty"`
|
||||
FilingDate *time.Time `json:"filing_date,omitempty"`
|
||||
GrantDate *time.Time `json:"grant_date,omitempty"`
|
||||
Court *string `json:"court,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
}
|
||||
|
||||
// ListFilter narrows List results. Zero-value → no filter.
|
||||
type ProjektFilter struct {
|
||||
Type string // "", or one of ProjektType* constants
|
||||
Status string // "", "active", "archived", "closed"
|
||||
ParentID *uuid.UUID // filter to direct children of the given parent; use ParentNullOnly for roots
|
||||
// ParentNullOnly restricts to root-level rows (parent_id IS NULL).
|
||||
// Mutually exclusive with ParentID.
|
||||
ParentNullOnly bool
|
||||
Search string // trigram / ILIKE on title, reference, client_number, matter_number
|
||||
}
|
||||
|
||||
// List returns Projekte visible to the user, filterable.
|
||||
func (s *ProjektService) List(ctx context.Context, userID uuid.UUID, f ProjektFilter) ([]models.Projekt, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return []models.Projekt{}, nil
|
||||
}
|
||||
|
||||
conds := []string{visibilityPredicate("p")}
|
||||
args := map[string]any{
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
}
|
||||
|
||||
if f.Type != "" {
|
||||
conds = append(conds, "p.type = :type")
|
||||
args["type"] = f.Type
|
||||
}
|
||||
if f.Status != "" {
|
||||
conds = append(conds, "p.status = :status")
|
||||
args["status"] = f.Status
|
||||
}
|
||||
if f.ParentNullOnly {
|
||||
conds = append(conds, "p.parent_id IS NULL")
|
||||
} else if f.ParentID != nil {
|
||||
conds = append(conds, "p.parent_id = :parent_id")
|
||||
args["parent_id"] = *f.ParentID
|
||||
}
|
||||
if s := strings.TrimSpace(f.Search); s != "" {
|
||||
conds = append(conds, `(p.title ILIKE :search OR p.reference ILIKE :search
|
||||
OR p.client_number ILIKE :search OR p.matter_number ILIKE :search)`)
|
||||
args["search"] = "%" + s + "%"
|
||||
}
|
||||
|
||||
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY p.updated_at DESC`
|
||||
|
||||
stmt, err := s.db.PrepareNamedContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare list projekte: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var rows []models.Projekt
|
||||
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
|
||||
return nil, fmt.Errorf("list projekte: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID returns the Projekt if the user can see it. Returns (nil, ErrNotVisible)
|
||||
// when invisible or missing — handlers must not distinguish.
|
||||
func (s *ProjektService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Projekt, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
var p models.Projekt
|
||||
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
|
||||
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2, 3)
|
||||
err = s.db.GetContext(ctx, &p, query, id, userID, user.Role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get projekt: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListChildren returns direct children of a Projekt (visibility-checked on parent).
|
||||
func (s *ProjektService) ListChildren(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) {
|
||||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.List(ctx, userID, ProjektFilter{ParentID: &id})
|
||||
}
|
||||
|
||||
// ListAncestors walks up the path and returns ancestors from root → parent
|
||||
// (exclusive of the Projekt itself). Used for breadcrumbs.
|
||||
func (s *ProjektService) ListAncestors(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) {
|
||||
p, err := s.GetByID(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels := strings.Split(p.Path, ".")
|
||||
if len(labels) <= 1 {
|
||||
return []models.Projekt{}, nil
|
||||
}
|
||||
// All but last = ancestors.
|
||||
ancestorIDs := labels[:len(labels)-1]
|
||||
ids := make([]uuid.UUID, 0, len(ancestorIDs))
|
||||
for _, s := range ancestorIDs {
|
||||
u, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ancestor uuid %q: %w", s, err)
|
||||
}
|
||||
ids = append(ids, u)
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return []models.Projekt{}, nil
|
||||
}
|
||||
// Ancestors are visible whenever the Projekt is (inheritance works both
|
||||
// ways through team membership checks). We still apply the predicate
|
||||
// for safety in case path is stale.
|
||||
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
|
||||
WHERE p.id = ANY($1::uuid[]) AND ` +
|
||||
visibilityPredicatePositional("p", 2, 3)
|
||||
// lib/pq doesn't serialise []uuid.UUID natively; render as string array.
|
||||
idStrs := make([]string, len(ids))
|
||||
for i, u := range ids {
|
||||
idStrs[i] = u.String()
|
||||
}
|
||||
var rows []models.Projekt
|
||||
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.Role); err != nil {
|
||||
return nil, fmt.Errorf("list ancestors: %w", err)
|
||||
}
|
||||
// Re-order to match path order (root first).
|
||||
order := make(map[uuid.UUID]int, len(ids))
|
||||
for i, id := range ids {
|
||||
order[id] = i
|
||||
}
|
||||
sortByOrder(rows, order)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetTree returns every Projekt in the subtree rooted at id (inclusive),
|
||||
// ordered depth-first. Visibility-checked at root; descendants that the
|
||||
// user can see are returned (the predicate naturally gates sub-branches).
|
||||
func (s *ProjektService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]models.Projekt, error) {
|
||||
root, err := s.GetByID(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return []models.Projekt{}, nil
|
||||
}
|
||||
// path LIKE root.path || '.%' OR path = root.path
|
||||
prefix := root.Path + ".%"
|
||||
query := `SELECT ` + projektColumns + ` FROM paliad.projekte p
|
||||
WHERE (p.path = $1 OR p.path LIKE $2)
|
||||
AND ` + visibilityPredicatePositional("p", 3, 4) + `
|
||||
ORDER BY p.path`
|
||||
var rows []models.Projekt
|
||||
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.Role); err != nil {
|
||||
return nil, fmt.Errorf("get tree: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Create inserts a new Projekt. If parent_id is set, the creator must have
|
||||
// visibility on the parent. The creator is auto-added to projekt_teams as
|
||||
// role='lead' in the same transaction so post-create SELECT picks up the row.
|
||||
func (s *ProjektService) Create(ctx context.Context, userID uuid.UUID, input CreateProjektInput) (*models.Projekt, error) {
|
||||
if strings.TrimSpace(input.Title) == "" {
|
||||
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
|
||||
}
|
||||
if !isValidProjektType(input.Type) {
|
||||
return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, input.Type)
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
|
||||
}
|
||||
if input.ParentID != nil {
|
||||
if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
|
||||
return nil, fmt.Errorf("%w: parent not visible", ErrForbidden)
|
||||
}
|
||||
}
|
||||
status := input.Status
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
if err := validateProjektStatus(status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// path is NOT NULL but the trigger populates it; supply a placeholder
|
||||
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projekte
|
||||
(id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number,
|
||||
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, '{}'::jsonb, $21, $21)`,
|
||||
id, input.Type, input.ParentID,
|
||||
input.Title, input.Reference, input.Description, status,
|
||||
userID,
|
||||
input.Industry, input.Country, input.BillingReference,
|
||||
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
|
||||
input.PatentNumber, input.FilingDate, input.GrantDate,
|
||||
input.Court, input.CaseNumber, input.ProceedingTypeID,
|
||||
now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert projekt: %w", err)
|
||||
}
|
||||
|
||||
// Auto-add creator as team lead so they (and RLS) can see the row.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', false, $2)`, id, userID); err != nil {
|
||||
return nil, fmt.Errorf("insert creator team row: %w", err)
|
||||
}
|
||||
|
||||
if err := insertProjektEvent(ctx, tx, id, userID, "projekt_created", "Projekt angelegt", nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create projekt: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// Update applies a partial update. Reparenting triggers path rewrite for the
|
||||
// subtree (handled by the AFTER UPDATE trigger on paliad.projekte).
|
||||
func (s *ProjektService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateProjektInput) (*models.Projekt, error) {
|
||||
current, err := s.GetByID(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ParentID != nil {
|
||||
// Verify new parent is visible (reparenting under invisible node would
|
||||
// leak the whole subtree to the new parent's team — reject).
|
||||
if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
|
||||
return nil, fmt.Errorf("%w: new parent not visible", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
next := 1
|
||||
appendSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
|
||||
if input.Title != nil {
|
||||
t := strings.TrimSpace(*input.Title)
|
||||
if t == "" {
|
||||
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
|
||||
}
|
||||
appendSet("title", t)
|
||||
}
|
||||
if input.Reference != nil {
|
||||
appendSet("reference", *input.Reference)
|
||||
}
|
||||
if input.Description != nil {
|
||||
appendSet("description", *input.Description)
|
||||
}
|
||||
if input.Status != nil {
|
||||
if err := validateProjektStatus(*input.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("status", *input.Status)
|
||||
}
|
||||
if input.ParentID != nil {
|
||||
appendSet("parent_id", *input.ParentID)
|
||||
}
|
||||
if input.Industry != nil {
|
||||
appendSet("industry", *input.Industry)
|
||||
}
|
||||
if input.Country != nil {
|
||||
appendSet("country", *input.Country)
|
||||
}
|
||||
if input.BillingReference != nil {
|
||||
appendSet("billing_reference", *input.BillingReference)
|
||||
}
|
||||
if input.ClientNumber != nil {
|
||||
appendSet("client_number", *input.ClientNumber)
|
||||
}
|
||||
if input.MatterNumber != nil {
|
||||
appendSet("matter_number", *input.MatterNumber)
|
||||
}
|
||||
if input.NetDocumentsURL != nil {
|
||||
appendSet("netdocuments_url", *input.NetDocumentsURL)
|
||||
}
|
||||
if input.PatentNumber != nil {
|
||||
appendSet("patent_number", *input.PatentNumber)
|
||||
}
|
||||
if input.FilingDate != nil {
|
||||
appendSet("filing_date", *input.FilingDate)
|
||||
}
|
||||
if input.GrantDate != nil {
|
||||
appendSet("grant_date", *input.GrantDate)
|
||||
}
|
||||
if input.Court != nil {
|
||||
appendSet("court", *input.Court)
|
||||
}
|
||||
if input.CaseNumber != nil {
|
||||
appendSet("case_number", *input.CaseNumber)
|
||||
}
|
||||
if input.ProceedingTypeID != nil {
|
||||
appendSet("proceeding_type_id", *input.ProceedingTypeID)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
appendSet("updated_at", time.Now().UTC())
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE paliad.projekte SET %s WHERE id = $%d",
|
||||
strings.Join(sets, ", "), next)
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update projekt: %w", err)
|
||||
}
|
||||
|
||||
if input.Status != nil && *input.Status != current.Status {
|
||||
desc := fmt.Sprintf("Status %s → %s", current.Status, *input.Status)
|
||||
if err := insertProjektEvent(ctx, tx, id, userID, "status_changed", desc, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.ParentID != nil {
|
||||
if err := insertProjektEvent(ctx, tx, id, userID, "projekt_reparented", "Projekt neu zugeordnet", nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update projekt: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// Delete archives the Projekt (soft-delete, status='archived'). Partner/admin only.
|
||||
// Hard-delete cascades through FK; we prefer archival for audit.
|
||||
func (s *ProjektService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role != "partner" && user.Role != "admin" {
|
||||
return fmt.Errorf("%w: only partners/admins can archive Projekte", ErrForbidden)
|
||||
}
|
||||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.projekte SET status = 'archived', updated_at = $1
|
||||
WHERE id = $2 AND status != 'archived'`, time.Now().UTC(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive projekt: %w", err)
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return tx.Commit()
|
||||
}
|
||||
if err := insertProjektEvent(ctx, tx, id, userID, "projekt_archived", "Projekt archiviert", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MaxEventsPageLimit caps ListEvents page size.
|
||||
const MaxEventsPageLimit = 200
|
||||
|
||||
// DefaultEventsPageLimit is the page size when ?limit= is omitted.
|
||||
const DefaultEventsPageLimit = 50
|
||||
|
||||
// ListEvents returns the audit trail for the Projekt, newest first, with
|
||||
// cursor pagination (before = uuid of last seen event).
|
||||
func (s *ProjektService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjektEvent, error) {
|
||||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = DefaultEventsPageLimit
|
||||
}
|
||||
if limit > MaxEventsPageLimit {
|
||||
limit = MaxEventsPageLimit
|
||||
}
|
||||
var beforeArg any
|
||||
if before != nil {
|
||||
beforeArg = *before
|
||||
}
|
||||
var events []models.ProjektEvent
|
||||
err := s.db.SelectContext(ctx, &events,
|
||||
`SELECT id, projekt_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at
|
||||
FROM paliad.projekt_events
|
||||
WHERE projekt_id = $1
|
||||
AND ($2::uuid IS NULL OR (created_at, id) < (
|
||||
SELECT created_at, id FROM paliad.projekt_events WHERE id = $2::uuid
|
||||
))
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $3`, id, beforeArg, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projekt events: %w", err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ResolveClientNumber walks up the path to find the first non-null client_number
|
||||
// (inherited convention). Returns nil if none in the ancestor chain.
|
||||
func (s *ProjektService) ResolveClientNumber(ctx context.Context, userID, id uuid.UUID) (*string, error) {
|
||||
p, err := s.GetByID(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.ClientNumber != nil {
|
||||
return p.ClientNumber, nil
|
||||
}
|
||||
ancestors, err := s.ListAncestors(ctx, userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ancestors returned root→parent; scan from closest ancestor outward —
|
||||
// but client_number is conceptually set at the root, so walking either
|
||||
// direction is fine. Closest wins for override.
|
||||
for i := len(ancestors) - 1; i >= 0; i-- {
|
||||
if ancestors[i].ClientNumber != nil {
|
||||
return ancestors[i].ClientNumber, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
// visibilityPredicate returns a SQL snippet that gates rows on
|
||||
// paliad.can_see_projekt-equivalent checks at the application layer. Uses
|
||||
// named bind variables :user_id and :role.
|
||||
//
|
||||
// Predicate: admin OR any (direct or ancestor) team membership of user_id.
|
||||
// Walks the path: projekt_teams.projekt_id = ANY(string_to_array(p.path, '.')::uuid[]).
|
||||
func visibilityPredicate(alias string) string {
|
||||
return `(:role = 'admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.projekt_teams pt
|
||||
WHERE pt.user_id = :user_id
|
||||
AND pt.projekt_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[])
|
||||
))`
|
||||
}
|
||||
|
||||
// visibilityPredicatePositional returns the same predicate with positional
|
||||
// placeholders ($userArg, $roleArg). Use when the surrounding query can't
|
||||
// use named parameters.
|
||||
func visibilityPredicatePositional(alias string, userArg, roleArg int) string {
|
||||
return fmt.Sprintf(`($%d = 'admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.projekt_teams pt
|
||||
WHERE pt.user_id = $%d
|
||||
AND pt.projekt_id = ANY(string_to_array(%s.path, '.')::uuid[])
|
||||
))`, roleArg, userArg, alias)
|
||||
}
|
||||
|
||||
// visibilityPredicatePlaceholder returns the predicate with ? placeholders
|
||||
// (for sqlx.In-compatible queries). The caller appends (userID, role) to
|
||||
// args in that order.
|
||||
func visibilityPredicatePlaceholder(alias string) string {
|
||||
return `(? = 'admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.projekt_teams pt
|
||||
WHERE pt.user_id = ?
|
||||
AND pt.projekt_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[])
|
||||
))`
|
||||
}
|
||||
|
||||
// Note: visibilityPredicatePlaceholder expects (role, user_id) in that
|
||||
// order. Callers must match. We document this inline where used.
|
||||
|
||||
// insertProjektEvent appends one audit row in the given tx.
|
||||
func insertProjektEvent(ctx context.Context, tx *sqlx.Tx, projektID, userID uuid.UUID, eventType, title string, description *string) error {
|
||||
now := time.Now().UTC()
|
||||
meta := json.RawMessage(`{}`)
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projekt_events
|
||||
(id, projekt_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $6, $6)`,
|
||||
uuid.New(), projektID, eventType, title, description, now, userID, meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert projekt_event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidProjektType(t string) bool {
|
||||
switch t {
|
||||
case ProjektTypeClient, ProjektTypeLitigation, ProjektTypePatent,
|
||||
ProjektTypeCase, ProjektTypeProject:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateProjektStatus(s string) error {
|
||||
switch s {
|
||||
case "active", "archived", "closed":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
|
||||
}
|
||||
|
||||
func sortByOrder(xs []models.Projekt, order map[uuid.UUID]int) {
|
||||
// Insertion sort — ancestor lists are short (<20).
|
||||
for i := 1; i < len(xs); i++ {
|
||||
for j := i; j > 0 && order[xs[j].ID] < order[xs[j-1].ID]; j-- {
|
||||
xs[j], xs[j-1] = xs[j-1], xs[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ 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,
|
||||
COALESCE(a.reference, '') AS akte_aktenzeichen,
|
||||
a.title AS akte_title,
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
@@ -180,7 +180,7 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin
|
||||
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
|
||||
|
||||
196
internal/services/team_service.go
Normal file
196
internal/services/team_service.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package services
|
||||
|
||||
// TeamService manages paliad.projekt_teams — project team memberships.
|
||||
//
|
||||
// Inheritance model (t-paliad-024): a user added at any ancestor of a Projekt
|
||||
// is implicitly a member of every descendant. Writes only ever touch the
|
||||
// direct level; inherited memberships are computed at read time by walking
|
||||
// UP the materialised path.
|
||||
//
|
||||
// The `inherited` column in the DB is reserved for potential future caching
|
||||
// of inherited rows. This service does not write inherited=true rows.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/models"
|
||||
)
|
||||
|
||||
// TeamService reads and writes paliad.projekt_teams.
|
||||
type TeamService struct {
|
||||
db *sqlx.DB
|
||||
projekte *ProjektService
|
||||
}
|
||||
|
||||
// NewTeamService wires the service.
|
||||
func NewTeamService(db *sqlx.DB, projekte *ProjektService) *TeamService {
|
||||
return &TeamService{db: db, projekte: projekte}
|
||||
}
|
||||
|
||||
// AddMember inserts a direct team membership. The caller must have visibility
|
||||
// on the Projekt (RLS + service-layer gate). Role defaults to 'associate'
|
||||
// if empty. Idempotent on (projekt_id, user_id) — a repeat call updates role.
|
||||
func (s *TeamService) AddMember(ctx context.Context, callerID, projektID, userID uuid.UUID, role string) (*models.ProjektTeamMember, error) {
|
||||
if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == "" {
|
||||
role = RoleAssociate
|
||||
}
|
||||
if !isValidRole(role) {
|
||||
return nil, fmt.Errorf("%w: invalid role %q", ErrInvalidInput, role)
|
||||
}
|
||||
|
||||
var m models.ProjektTeamMember
|
||||
err := s.db.GetContext(ctx, &m,
|
||||
`INSERT INTO paliad.projekt_teams (projekt_id, user_id, role, inherited, added_by)
|
||||
VALUES ($1, $2, $3, false, $4)
|
||||
ON CONFLICT (projekt_id, user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role
|
||||
RETURNING id, projekt_id, user_id, role, inherited, added_by, created_at`,
|
||||
projektID, userID, role, callerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add team member: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// RemoveMember deletes a direct team membership. Inherited memberships (from
|
||||
// ancestors) can't be removed at the child level — the caller must remove
|
||||
// the ancestor row to break the inheritance.
|
||||
func (s *TeamService) RemoveMember(ctx context.Context, callerID, projektID, userID uuid.UUID) error {
|
||||
if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.projekt_teams
|
||||
WHERE projekt_id = $1 AND user_id = $2 AND inherited = false`,
|
||||
projektID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove team member: %w", err)
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDirectMembers returns only the direct (non-inherited) team members,
|
||||
// enriched with user display fields.
|
||||
func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjektTeamMemberWithUser, error) {
|
||||
if _, err := s.projekte.GetByID(ctx, callerID, projektID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []models.ProjektTeamMemberWithUser
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT pt.id, pt.projekt_id, pt.user_id, pt.role, pt.inherited,
|
||||
pt.added_by, pt.created_at,
|
||||
u.email AS user_email,
|
||||
u.display_name AS user_display_name,
|
||||
u.office AS user_office,
|
||||
NULL::uuid AS inherited_from_id,
|
||||
NULL::text AS inherited_from_title
|
||||
FROM paliad.projekt_teams pt
|
||||
LEFT JOIN paliad.users u ON u.id = pt.user_id
|
||||
WHERE pt.projekt_id = $1
|
||||
ORDER BY pt.role, u.display_name`, projektID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list direct team: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListEffectiveMembers returns direct + inherited members of a Projekt.
|
||||
// Rows coming from an ancestor carry Inherited=true + InheritedFromID/Title.
|
||||
// If the same user is both direct and inherited, the direct row wins.
|
||||
func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projektID uuid.UUID) ([]models.ProjektTeamMemberWithUser, error) {
|
||||
projekt, err := s.projekte.GetByID(ctx, callerID, projektID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ancestorIDs := pathToIDStrings(projekt.Path)
|
||||
|
||||
query := `
|
||||
WITH candidate AS (
|
||||
SELECT pt.id, pt.projekt_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
|
||||
(pt.projekt_id <> $1) AS inherited,
|
||||
CASE WHEN pt.projekt_id <> $1 THEN pt.projekt_id END AS inherited_from_id,
|
||||
CASE WHEN pt.projekt_id <> $1 THEN parent.title END AS inherited_from_title
|
||||
FROM paliad.projekt_teams pt
|
||||
LEFT JOIN paliad.projekte parent ON parent.id = pt.projekt_id
|
||||
WHERE pt.projekt_id = ANY($2::uuid[])
|
||||
),
|
||||
ranked AS (
|
||||
SELECT c.*, ROW_NUMBER() OVER (
|
||||
PARTITION BY c.user_id
|
||||
ORDER BY c.inherited ASC, c.created_at ASC
|
||||
) AS rn FROM candidate c
|
||||
)
|
||||
SELECT r.id, r.projekt_id, r.user_id, r.role, r.inherited,
|
||||
r.added_by, r.created_at,
|
||||
u.email AS user_email,
|
||||
u.display_name AS user_display_name,
|
||||
u.office AS user_office,
|
||||
r.inherited_from_id,
|
||||
r.inherited_from_title
|
||||
FROM ranked r
|
||||
LEFT JOIN paliad.users u ON u.id = r.user_id
|
||||
WHERE r.rn = 1
|
||||
ORDER BY r.inherited ASC, r.role, u.display_name`
|
||||
|
||||
var rows []models.ProjektTeamMemberWithUser
|
||||
if err := s.db.SelectContext(ctx, &rows, query, projektID, pq.StringArray(ancestorIDs)); err != nil {
|
||||
return nil, fmt.Errorf("list effective team: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// IsEffectiveMember reports whether userID is a direct or inherited member of projektID.
|
||||
func (s *TeamService) IsEffectiveMember(ctx context.Context, projektID, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := s.db.GetContext(ctx, &ok,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.projekte p
|
||||
JOIN paliad.projekt_teams pt
|
||||
ON pt.projekt_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
WHERE p.id = $1 AND pt.user_id = $2
|
||||
)`, projektID, userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check effective membership: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func isValidRole(r string) bool {
|
||||
switch r {
|
||||
case RoleLead, RoleAssociate, RolePA, RoleOfCounsel,
|
||||
RoleLocalCounsel, RoleExpert, RoleObserver:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pathToIDStrings splits a materialised path into its UUID labels as strings,
|
||||
// suitable for pq.StringArray → uuid[] cast.
|
||||
func pathToIDStrings(path string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(path, ".")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user