Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.
Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:
* paliad.users.job_title — free text, NULL allowed, display only.
NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
default 'standard'. The only thing that gates ops.
Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
with the historic u.role IN ('partner','admin') gates simplified
to u.global_role='global_admin' only (job_title NEVER gates)
Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
global_role with last-admin demotion guard (ErrLastGlobalAdmin);
IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
department/party/note/checklist_instance): pass user.GlobalRole into
visibility predicates; partner-or-admin gates simplified to
global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
-> JobTitle (*string)
Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
'Standard'|'Global Admin' badge + dropdown editor; last-admin
demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
gates dropped, only global_admin grants those operations
Tests:
* user_service_test: bootstrap promotes first user to global_admin,
subsequent default to standard; AdminUpdateUser refuses to demote
the last global_admin; IsAdmin reads global_role
Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
346 lines
19 KiB
Go
346 lines
19 KiB
Go
// Package models holds the database row types for paliad.* tables.
|
|
// Names are English throughout; only user-facing i18n strings live in the
|
|
// frontend. See internal/db/migrations/ for the canonical schema definitions.
|
|
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// User extends auth.users with firm-specific profile fields. Created by the
|
|
// Phase D onboarding flow; without a row here, the user can't see any Projects.
|
|
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"`
|
|
// JobTitle is free-text display only ("Partner", "Counsel", "PA",
|
|
// "Counsel Knowledge Lawyer", …). NULL is allowed for users who never
|
|
// picked a title — typically global admins promoted via SQL.
|
|
JobTitle *string `db:"job_title" json:"job_title"`
|
|
// GlobalRole is the global-permissions enum: 'standard' | 'global_admin'.
|
|
// Drives every permission gate that used to look at the legacy
|
|
// role='admin'. Per-project authority is on paliad.project_teams.role and
|
|
// is unrelated.
|
|
GlobalRole string `db:"global_role" json:"global_role"`
|
|
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
|
|
Lang string `db:"lang" json:"lang"`
|
|
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
|
|
// ReminderMorningTime / ReminderEveningTime are stored as Postgres TIME and
|
|
// scanned as strings in HH:MM:SS form so we don't need a separate type and
|
|
// the JSON shape stays trivially editable from the settings page.
|
|
ReminderMorningTime string `db:"reminder_morning_time" json:"reminder_morning_time"`
|
|
ReminderEveningTime string `db:"reminder_evening_time" json:"reminder_evening_time"`
|
|
ReminderTimezone string `db:"reminder_timezone" json:"reminder_timezone"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// Project is one node in the paliad.projects tree. Visibility is team-based
|
|
// (direct or inherited via the materialised path) — see paliad.can_see_project.
|
|
// Type-specific fields are nullable; the service layer enforces the subset
|
|
// that applies to each type.
|
|
type Project struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
Type string `db:"type" json:"type"`
|
|
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
|
// Path is the '.'-joined UUID list from root to self (inclusive).
|
|
// Maintained by a Postgres trigger — writes from the service are ignored.
|
|
Path string `db:"path" json:"path"`
|
|
Title string `db:"title" json:"title"`
|
|
Reference *string `db:"reference" json:"reference,omitempty"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
Status string `db:"status" json:"status"`
|
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
|
|
|
// Client-specific (type='client'), nullable otherwise.
|
|
Industry *string `db:"industry" json:"industry,omitempty"`
|
|
Country *string `db:"country" json:"country,omitempty"`
|
|
BillingReference *string `db:"billing_reference" json:"billing_reference,omitempty"`
|
|
|
|
// ClientMatter numbers — external HLC billing/DMS identifiers.
|
|
// Child rows inherit client_number from the root by default (resolved at
|
|
// read time by the service); a child with its own client_number overrides.
|
|
// matter_number is assigned independently at any level.
|
|
ClientNumber *string `db:"client_number" json:"client_number,omitempty"`
|
|
MatterNumber *string `db:"matter_number" json:"matter_number,omitempty"`
|
|
NetDocumentsURL *string `db:"netdocuments_url" json:"netdocuments_url,omitempty"`
|
|
|
|
// Patent-specific (type='patent').
|
|
PatentNumber *string `db:"patent_number" json:"patent_number,omitempty"`
|
|
FilingDate *time.Time `db:"filing_date" json:"filing_date,omitempty"`
|
|
GrantDate *time.Time `db:"grant_date" json:"grant_date,omitempty"`
|
|
|
|
// Case-specific (type='case').
|
|
Court *string `db:"court" json:"court,omitempty"`
|
|
CaseNumber *string `db:"case_number" json:"case_number,omitempty"`
|
|
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
|
|
|
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
|
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// ProjectTeamMember is one row of paliad.project_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 ProjectTeamMember struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID uuid.UUID `db:"project_id" json:"project_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"`
|
|
}
|
|
|
|
// ProjectTeamMemberWithUser 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 ProjectTeamMemberWithUser struct {
|
|
ProjectTeamMember
|
|
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 project_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"`
|
|
}
|
|
|
|
// Department is one structural partner unit. Department membership is
|
|
// orthogonal to project teams — a user typically belongs to exactly one
|
|
// Department but may work on projects across all of them.
|
|
type Department 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"`
|
|
}
|
|
|
|
// DepartmentMember is one user's membership in a Department.
|
|
type DepartmentMember struct {
|
|
DepartmentID uuid.UUID `db:"department_id" json:"department_id"`
|
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
// ProjectEvent is one row in the per-Project audit trail
|
|
// (paliad.project_events, renamed from paliad.project_events in migration 018).
|
|
type ProjectEvent struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
|
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
|
Title string `db:"title" json:"title"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
|
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
|
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// Deadline is one persistent deadline attached to a Project (typically a
|
|
// case- or patent-level node). Visibility is inherited from the parent
|
|
// Project via paliad.can_see_project.
|
|
type Deadline struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
|
Title string `db:"title" json:"title"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
DueDate time.Time `db:"due_date" json:"due_date"`
|
|
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
|
|
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
|
|
Source string `db:"source" json:"source"`
|
|
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
|
|
Status string `db:"status" json:"status"`
|
|
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
|
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
|
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
|
Notes *string `db:"notes" json:"notes,omitempty"`
|
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// DeadlineWithProject enriches a Deadline with parent-Project display fields
|
|
// (reference + title) for list views.
|
|
type DeadlineWithProject struct {
|
|
Deadline
|
|
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
|
ProjectTitle string `db:"project_title" json:"project_title"`
|
|
ProjectType string `db:"project_type" json:"project_type"`
|
|
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
|
}
|
|
|
|
// Appointment is one appointment. project_id is nullable: NULL = personal
|
|
// (creator-only); set = follows the parent Project's team visibility.
|
|
type Appointment struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
|
Title string `db:"title" json:"title"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
StartAt time.Time `db:"start_at" json:"start_at"`
|
|
EndAt *time.Time `db:"end_at" json:"end_at,omitempty"`
|
|
Location *string `db:"location" json:"location,omitempty"`
|
|
AppointmentType *string `db:"appointment_type" json:"appointment_type,omitempty"`
|
|
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
|
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// AppointmentWithProject enriches an Appointment with its parent Project
|
|
// display fields for list views. All fields nullable because personal
|
|
// Appointments have no parent.
|
|
type AppointmentWithProject struct {
|
|
Appointment
|
|
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
|
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
|
ProjectType *string `db:"project_type" json:"project_type,omitempty"`
|
|
}
|
|
|
|
// Note is one polymorphic note attached to exactly one parent row
|
|
// (Project, Deadline, Appointment, or ProjectEvent). Visibility follows the
|
|
// parent.
|
|
type Note struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
|
DeadlineID *uuid.UUID `db:"deadline_id" json:"deadline_id,omitempty"`
|
|
AppointmentID *uuid.UUID `db:"appointment_id" json:"appointment_id,omitempty"`
|
|
ProjectEventID *uuid.UUID `db:"project_event_id" json:"project_event_id,omitempty"`
|
|
Content string `db:"content" json:"content"`
|
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
|
|
// Author display fields populated by the service's LEFT JOIN to
|
|
// paliad.users so the UI can render "von <Name>" without a lookup.
|
|
AuthorName *string `db:"author_name" json:"author_name,omitempty"`
|
|
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
|
|
}
|
|
|
|
// ChecklistInstance is one user's instantiation of a static checklist
|
|
// template (defined in internal/checklists). Checkbox state lives in the
|
|
// `state` jsonb column.
|
|
//
|
|
// Visibility mirrors Appointment: project_id nullable. Personal instances
|
|
// (project_id NULL) are creator-only; Project-linked instances follow
|
|
// paliad.can_see_project.
|
|
type ChecklistInstance struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
TemplateSlug string `db:"template_slug" json:"template_slug"`
|
|
Name string `db:"name" json:"name"`
|
|
ProjectID *uuid.UUID `db:"project_id" json:"project_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"`
|
|
}
|
|
|
|
// ChecklistInstanceWithProject enriches an instance with its parent Project
|
|
// reference fields for list views.
|
|
type ChecklistInstanceWithProject struct {
|
|
ChecklistInstance
|
|
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
|
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
|
}
|
|
|
|
// UserCalDAVConfig holds one user's external CalDAV connection. The password
|
|
// is never returned in API responses; only the public fields are exposed.
|
|
type UserCalDAVConfig struct {
|
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
|
URL string `db:"url" json:"url"`
|
|
Username string `db:"username" json:"username"`
|
|
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
|
|
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
|
Enabled bool `db:"enabled" json:"enabled"`
|
|
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
|
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// CalDAVSyncLogEntry is one historical sync record.
|
|
type CalDAVSyncLogEntry struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
|
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
|
|
Direction string `db:"direction" json:"direction"`
|
|
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
|
|
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
|
|
Error *string `db:"error" json:"error,omitempty"`
|
|
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
|
|
}
|
|
|
|
// Party is a party to a Project (Kläger, Beklagter, etc. — typically on
|
|
// a case-level project).
|
|
type Party struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
|
Name string `db:"name" json:"name"`
|
|
Role *string `db:"role" json:"role,omitempty"`
|
|
Representative *string `db:"representative" json:"representative,omitempty"`
|
|
ContactInfo json.RawMessage `db:"contact_info" json:"contact_info"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
|
type DeadlineRule struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
|
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
|
Code *string `db:"code" json:"code,omitempty"`
|
|
Name string `db:"name" json:"name"`
|
|
NameEN string `db:"name_en" json:"name_en"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
|
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
|
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
|
|
DurationValue int `db:"duration_value" json:"duration_value"`
|
|
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
|
Timing *string `db:"timing" json:"timing,omitempty"`
|
|
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
|
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
|
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
|
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
|
|
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
|
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
|
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
|
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
|
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
|
IsActive bool `db:"is_active" json:"is_active"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
|
// management) or UPC_*/DE_*/EPA_*/EP_GRANT (Fristenrechner UI).
|
|
type ProceedingType struct {
|
|
ID int `db:"id" json:"id"`
|
|
Code string `db:"code" json:"code"`
|
|
Name string `db:"name" json:"name"`
|
|
NameEN string `db:"name_en" json:"name_en"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
|
Category *string `db:"category" json:"category,omitempty"`
|
|
DefaultColor string `db:"default_color" json:"default_color"`
|
|
SortOrder int `db:"sort_order" json:"sort_order"`
|
|
IsActive bool `db:"is_active" json:"is_active"`
|
|
}
|