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).
226 lines
7.8 KiB
Go
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
|
|
}
|