Files
paliad/internal/services/name_composition_spec.go
mAi a05ae1f2ae feat(settings): firm-wide default name compositions (t-paliad-356 Slice 5)
Completes the nomen train (S1–S5). Adds the FIRM tier of the name-composition
precedence chain — per-document → user → FIRM → system (PRD §3.1/§3.2) —
mirroring firm_dashboard_default exactly.

Storage + service:
- Migration 162: paliad.firm_name_compositions singleton (id=1, CHECK id=1,
  RLS read-all + service-role writes) — same shape as firm_dashboard_default
  (mig 117), holding a validated { artifact_id: Composition } jsonb map.
- FirmNameCompositionService (Get/Set/Clear) + getFirmNameCompositions /
  setFirmNameCompositions / clearFirmNameCompositions singleton helpers in
  name_composition_spec.go.

Resolution:
- resolveComposition is now variadic over ordered specs (user, firm); first
  valid wins, else system default. Existing single-spec callers unchanged.
- Render path threads the firm tier: renderSubmissionDraftTitle /
  RenderSubmissionFilenameFor gain a firm param; newDraftName +
  submissionDownloadFilename load it (nil-safe). A firm default thus changes
  the effective name for every user without a personal override.

Admin surface (mirrors firm_dashboard_default):
- GET/PUT/DELETE /api/admin/name-compositions{/artifact_id} (adminGate) read
  back / set / clear the firm default per artifact.
- /settings Namensschemata cards gain an admin-only "Firmenstandard" block
  (set from the current template field / clear) revealed via is_admin, plus a
  "Firmenstandard" badge for non-admin users whose effective name comes from
  the firm tier. SettingsNameArtifact now resolves user→firm→system and
  exposes firm_is_set/firm_template.

Tests: pure precedence (user>firm>system) + firm-tier view + live firm
round-trip/Validate-rejection (via db.ApplyMigrations). go vet, go test ./...,
bun build all clean; gated live tests green against TEST_DATABASE_URL.

NOTE (merge ordering): golang-migrate is forward-only. Migration 162 must not
reach a DB before bohr's 161 (Rubrum Composer seed) exists, or 161 will be
skipped (current>161 → never applied). Merge 161 before/with 162.

Browser Playwright of the admin firm controls deferred to post-deploy
mai-tester — shared Supabase login wall blocks pre-merge browser login (same
ceiling as t-paliad-354).
2026-06-01 13:04:11 +02:00

226 lines
7.8 KiB
Go

package services
// Per-user name-composition overrides (t-paliad-356 Slice 3, PRD §3).
//
// users.name_compositions is a JSONB map { artifact_id: Composition } that
// overrides the code-resident system default for an artifact. The validation
// surface mirrors DashboardLayoutSpec exactly: Validate on write (known
// artifact, segments reference known variables, version + segment cap),
// SanitizeForRead on read (drop unknown artifacts and segments referencing
// variables the catalog no longer has, clamp version). Resolution prefers a
// valid user override over the system default; the firm slot (PRD §3.1) is
// reserved for Slice 5 and not wired yet, so the system default is the
// fallback directly below the user level in Slice 3.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
// NameCompositionSpec is the parsed users.name_compositions jsonb: a map of
// artifact_id -> overriding Composition. It marshals as the bare map.
type NameCompositionSpec map[string]nomen.Composition
// Validate enforces the write-time invariants: every key is a known artifact
// and every composition is valid against that artifact's variable catalog.
func (s NameCompositionSpec) Validate() error {
for id, comp := range s {
art, ok := NameArtifact(id)
if !ok {
return fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, id)
}
if err := comp.Validate(art.Catalog); err != nil {
return fmt.Errorf("%w: artifact %q: %v", ErrInvalidInput, id, err)
}
}
return nil
}
// SanitizeForRead applies the forgiving read-path rules: drop overrides for
// artifacts that no longer exist, and within each surviving override drop
// segments referencing unknown variables and clamp the version. Mutates the
// receiver; returns true if anything changed so the caller can persist the
// cleaned value.
func (s NameCompositionSpec) SanitizeForRead() bool {
changed := false
for id, comp := range s {
art, ok := NameArtifact(id)
if !ok {
delete(s, id)
changed = true
continue
}
if comp.SanitizeForRead(art.Catalog) {
changed = true
}
s[id] = comp
}
return changed
}
// ParseNameCompositionSpec decodes and validates a name_compositions payload.
// Used on writes (API/test). An empty/NULL payload yields an empty spec.
func ParseNameCompositionSpec(b []byte) (NameCompositionSpec, error) {
spec := NameCompositionSpec{}
if len(b) > 0 {
if err := json.Unmarshal(b, &spec); err != nil {
return nil, fmt.Errorf("%w: name_compositions JSON decode: %v", ErrInvalidInput, err)
}
}
if err := spec.Validate(); err != nil {
return nil, err
}
return spec, nil
}
// resolveComposition returns the first valid override for an artifact from the
// supplied specs (highest precedence first), else the artifact's system
// default. The precedence chain is per-document → user → firm → system (PRD
// §3.1); the per-document layer is a variable-value override resolved in the
// VarResolver, not here, so the specs passed are [user, firm] in that order
// (Slice 5). A stored override is sanitised then validated; anything that
// fails validation is skipped so a broken stored value can never render — the
// next valid tier (or the system default) wins.
func resolveComposition(artifactID string, specs ...NameCompositionSpec) nomen.Composition {
art := nameArtifacts[artifactID]
for _, spec := range specs {
if spec == nil {
continue
}
comp, ok := spec[artifactID]
if !ok {
continue
}
comp.SanitizeForRead(art.Catalog)
if len(comp.Segments) > 0 && comp.Validate(art.Catalog) == nil {
return comp
}
}
return art.SystemDefault
}
// getUserNameCompositions loads a user's name_compositions, sanitised for
// read. A missing user or NULL column yields an empty (nil-safe) spec — the
// caller then renders with system defaults. Shared by the title create path
// and the filename download path so the SELECT lives in one place.
func getUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID) (NameCompositionSpec, error) {
var raw []byte
err := db.GetContext(ctx, &raw,
`SELECT name_compositions FROM paliad.users WHERE id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return NameCompositionSpec{}, nil
}
if err != nil {
return nil, fmt.Errorf("load name_compositions: %w", err)
}
spec := NameCompositionSpec{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &spec); err != nil {
// A corrupt stored value must not break draft creation — treat
// it as "no overrides" and let the next write replace it.
return NameCompositionSpec{}, nil
}
}
spec.SanitizeForRead()
return spec, nil
}
// getFirmNameCompositions loads the firm-wide default name_compositions
// (Slice 5), sanitised for read. A missing singleton row yields an empty
// (nil-safe) spec — the caller then renders with the user override or the
// system default. Shared by the render path and the admin service so the
// SELECT lives in one place; mirrors getUserNameCompositions.
func getFirmNameCompositions(ctx context.Context, db *sqlx.DB) (NameCompositionSpec, error) {
var raw []byte
err := db.GetContext(ctx, &raw,
`SELECT compositions_json FROM paliad.firm_name_compositions WHERE id = 1`)
if errors.Is(err, sql.ErrNoRows) {
return NameCompositionSpec{}, nil
}
if err != nil {
return nil, fmt.Errorf("load firm_name_compositions: %w", err)
}
spec := NameCompositionSpec{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &spec); err != nil {
// A corrupt stored value must not break name rendering — treat it
// as "no firm default" and let the next admin write replace it.
return NameCompositionSpec{}, nil
}
}
spec.SanitizeForRead()
return spec, nil
}
// setFirmNameCompositions validates and upserts the firm-wide default map into
// the id=1 singleton, recording updatedBy (uuid.Nil clears the column). The
// admin API is the only writer.
func setFirmNameCompositions(ctx context.Context, db *sqlx.DB, spec NameCompositionSpec, updatedBy uuid.UUID) error {
if err := spec.Validate(); err != nil {
return err
}
if spec == nil {
spec = NameCompositionSpec{}
}
b, err := json.Marshal(spec)
if err != nil {
return fmt.Errorf("marshal firm_name_compositions: %w", err)
}
var updaterArg any
if updatedBy != uuid.Nil {
updaterArg = updatedBy
}
_, err = db.ExecContext(ctx, `
INSERT INTO paliad.firm_name_compositions (id, compositions_json, updated_by, updated_at)
VALUES (1, $1::jsonb, $2, now())
ON CONFLICT (id) DO UPDATE
SET compositions_json = EXCLUDED.compositions_json,
updated_by = EXCLUDED.updated_by,
updated_at = now()
`, json.RawMessage(b), updaterArg)
if err != nil {
return fmt.Errorf("persist firm_name_compositions: %w", err)
}
return nil
}
// clearFirmNameCompositions deletes the firm default so resolution falls
// through to the system default. Idempotent.
func clearFirmNameCompositions(ctx context.Context, db *sqlx.DB) error {
if _, err := db.ExecContext(ctx, `DELETE FROM paliad.firm_name_compositions WHERE id = 1`); err != nil {
return fmt.Errorf("clear firm_name_compositions: %w", err)
}
return nil
}
// setUserNameCompositions validates and persists a user's full
// name_compositions map. The S4 settings API and the Slice-3 live tests call
// this; it is the single write path.
func setUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID, spec NameCompositionSpec) error {
if err := spec.Validate(); err != nil {
return err
}
if spec == nil {
spec = NameCompositionSpec{}
}
b, err := json.Marshal(spec)
if err != nil {
return fmt.Errorf("marshal name_compositions: %w", err)
}
_, err = db.ExecContext(ctx,
`UPDATE paliad.users SET name_compositions = $1::jsonb WHERE id = $2`,
json.RawMessage(b), userID)
if err != nil {
return fmt.Errorf("persist name_compositions: %w", err)
}
return nil
}