Files
paliad/internal/services/scenario_flags_service.go
mAi d36cc9ee15 feat(deadline-system): P0 — per-project scenario_flags SSoT (m/paliad#149)
Phase 2 P0 of the deadline + procedural-events revision. Establishes
paliad.projects.scenario_flags (jsonb) + paliad.scenario_flag_catalog as
the single source of truth for per-project scenario state — replacing
the three fragmented stores athena flagged (project_event_choices,
scenarios.spec, DOM-only). All three were empty per the audit so no
data migration is needed.

The jsonb map carries two key shapes:

  * named flags (whitelist via scenario_flag_catalog) — today
    with_ccr / with_amend / with_cci
  * per-rule selection deviations of shape "rule:<uuid>" — wired up
    here for validation; the consumer UI lands in P3

Endpoints:

  GET   /api/projects/{id}/scenario-flags
  PATCH /api/projects/{id}/scenario-flags

PATCH semantics: bool = write; null = delete (priority-driven default
returns); missing key = leave alone. The service validates every key
on write (catalog lookup + UUID rule-membership + mandatory-cannot-be-
deselected) before persisting, so a single bad key fails the whole
patch.

Frontend bind: new scenario-flags.ts client module + Mode B's flag
checkboxes (ccr-flag / inf-amend-flag / rev-amend-flag / rev-cci-flag)
now hydrate from / persist to the project's scenario_flags on every
toggle. Kontextfrei (no project) is unchanged. Cross-surface coherence
via a scenario-flag-changed CustomEvent (peer surfaces — Verfahrens-
ablauf strip, Mode B result-view — will subscribe in P3).

Mig 154 is audit-defensive (set_config of paliad.audit_reason); no
audit trigger fires on paliad.projects today but a future one will
inherit the reason. Seeds the three known flags. CHECK constraints
enforce the top-level shape (jsonb_typeof = 'object') and the
catalog key pattern (lowercase, not 'rule:%' prefix).

Verified against the live DB: 18 projects default to '{}', catalog
has 3 rows, applied_migrations advanced to 154.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.3, §2.4a,
§4.1, §5 (P0 row). t-paliad-331.
2026-05-27 15:02:01 +02:00

376 lines
12 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// ScenarioFlagsService owns the per-project scenario state — the
// single source of truth introduced in mig 154 (m/paliad#149 Phase 2 P0).
//
// The state lives in paliad.projects.scenario_flags (jsonb object) and
// carries two key shapes:
//
// - **Named flags** — keys whose name appears in paliad.scenario_flag_catalog
// (today: with_ccr / with_amend / with_cci). These drive condition_expr
// evaluation in pkg/litigationplanner and the Verfahrensablauf
// scenario-strip UI.
//
// - **Per-rule selection deviations** — keys of shape "rule:<uuid>".
// They record an explicit deviation from the rule's priority-driven
// default (mandatory always selected; recommended default-selected;
// optional default-unselected). The UUID must resolve to an
// active+published sequencing_rule on the project's proceeding type.
//
// Values are always JSON booleans. Missing keys take the priority-driven
// default — the absence of an entry is the absence of a deviation.
//
// All writes go through Patch (PATCH semantics: keys not in the delta are
// left untouched; passing `null` for a key deletes it from the map so the
// default behaviour returns). Patch validates every key + every UUID
// before persisting; a single bad key fails the whole patch.
type ScenarioFlagsService struct {
db *sqlx.DB
projects *ProjectService
}
func NewScenarioFlagsService(db *sqlx.DB, projects *ProjectService) *ScenarioFlagsService {
return &ScenarioFlagsService{db: db, projects: projects}
}
// ScenarioFlagCatalogEntry mirrors one row of paliad.scenario_flag_catalog.
type ScenarioFlagCatalogEntry struct {
FlagKey string `db:"flag_key" json:"flag_key"`
LabelDE string `db:"label_de" json:"label_de"`
LabelEN string `db:"label_en" json:"label_en"`
Description *string `db:"description" json:"description,omitempty"`
HiddenUnlessSet bool `db:"hidden_unless_set" json:"hidden_unless_set"`
}
// ScenarioFlagsView is the GET response shape — the live flag map plus
// the catalog the UI needs to render the scenario-flags strip.
type ScenarioFlagsView struct {
Flags map[string]bool `json:"flags"`
Catalog []ScenarioFlagCatalogEntry `json:"catalog"`
}
// rulePrefix is the prefix that distinguishes a per-rule selection
// entry from a named flag. Kept lowercase to match the catalog's
// CHECK constraint pattern.
const rulePrefix = "rule:"
// ruleKeyRe parses "rule:<uuid>" into the UUID portion. Uses the
// case-insensitive uuid regex so callers can paste either lower or
// uppercase UUIDs.
var ruleKeyRe = regexp.MustCompile(`^rule:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`)
// Get returns the current scenario state for a project. Visibility-gated
// via paliad.can_see_project (mirrors EventChoiceService.requireProjectVisible).
//
// The returned map is never nil; an empty object means "every rule takes
// the priority-driven default". The catalog is always populated so the
// UI can render the scenario-strip without a second round-trip.
func (s *ScenarioFlagsService) Get(ctx context.Context, userID, projectID uuid.UUID) (*ScenarioFlagsView, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
var raw []byte
err := s.db.GetContext(ctx, &raw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("read scenario_flags: %w", err)
}
flags, err := decodeFlagMap(raw)
if err != nil {
return nil, fmt.Errorf("decode scenario_flags: %w", err)
}
catalog, err := s.ListCatalog(ctx)
if err != nil {
return nil, err
}
return &ScenarioFlagsView{Flags: flags, Catalog: catalog}, nil
}
// ListCatalog returns every paliad.scenario_flag_catalog row, ordered by
// added_at so the seeded with_ccr / with_amend / with_cci tier surfaces
// first and later-added flags appear after.
func (s *ScenarioFlagsService) ListCatalog(ctx context.Context) ([]ScenarioFlagCatalogEntry, error) {
out := []ScenarioFlagCatalogEntry{}
if err := s.db.SelectContext(ctx, &out,
`SELECT flag_key, label_de, label_en, description, hidden_unless_set
FROM paliad.scenario_flag_catalog
ORDER BY added_at ASC, flag_key ASC`); err != nil {
return nil, fmt.Errorf("list flag catalog: %w", err)
}
return out, nil
}
// Patch merges a partial delta into the project's scenario_flags. Per
// the design (§2.3): keys not in the delta are left untouched; a key
// set to `nil` (JSON null) is deleted from the map so the default
// returns; bool values are stored verbatim.
//
// Every key in the delta is validated before any write happens:
//
// - keys matching "rule:<uuid>" must resolve to an active+published
// sequencing_rule whose proceeding_type matches the project's
// proceeding_type_id;
// - all other keys must appear in paliad.scenario_flag_catalog.
//
// Bad keys raise ErrInvalidInput with a message that names the offending
// key. The whole patch is rejected on the first bad key — no partial
// writes.
func (s *ScenarioFlagsService) Patch(
ctx context.Context,
userID, projectID uuid.UUID,
delta map[string]*bool,
) (*ScenarioFlagsView, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
if len(delta) == 0 {
return s.Get(ctx, userID, projectID)
}
if err := s.validateDelta(ctx, projectID, delta); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := setAuditReasonTx(ctx, tx,
fmt.Sprintf("scenario-flags PATCH by user %s on project %s", userID, projectID)); err != nil {
return nil, err
}
var raw []byte
if err := tx.GetContext(ctx, &raw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1 FOR UPDATE`,
projectID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
return nil, fmt.Errorf("lock project row: %w", err)
}
current, err := decodeFlagMap(raw)
if err != nil {
return nil, fmt.Errorf("decode current scenario_flags: %w", err)
}
for k, v := range delta {
if v == nil {
delete(current, k)
continue
}
current[k] = *v
}
merged, err := json.Marshal(current)
if err != nil {
return nil, fmt.Errorf("encode merged scenario_flags: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.projects
SET scenario_flags = $1::jsonb,
updated_at = now()
WHERE id = $2`, merged, projectID); err != nil {
return nil, fmt.Errorf("write scenario_flags: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit scenario-flags patch: %w", err)
}
catalog, err := s.ListCatalog(ctx)
if err != nil {
return nil, err
}
return &ScenarioFlagsView{Flags: current, Catalog: catalog}, nil
}
// validateDelta runs every key in the delta through the appropriate
// validator. Returns the first error it finds — callers receive
// ErrInvalidInput wrapped with the offending key.
func (s *ScenarioFlagsService) validateDelta(
ctx context.Context,
projectID uuid.UUID,
delta map[string]*bool,
) error {
var (
ruleUUIDs []uuid.UUID
flagKeys []string
ruleIDsKey = map[string]uuid.UUID{}
)
for k := range delta {
if k == "" {
return fmt.Errorf("%w: empty key in scenario_flags delta", ErrInvalidInput)
}
if m := ruleKeyRe.FindStringSubmatch(k); m != nil {
u, err := uuid.Parse(m[1])
if err != nil {
return fmt.Errorf("%w: %q has malformed UUID", ErrInvalidInput, k)
}
ruleUUIDs = append(ruleUUIDs, u)
ruleIDsKey[k] = u
continue
}
flagKeys = append(flagKeys, k)
}
if len(flagKeys) > 0 {
known, err := s.knownFlagKeys(ctx, flagKeys)
if err != nil {
return err
}
for _, k := range flagKeys {
if _, ok := known[k]; !ok {
return fmt.Errorf("%w: scenario flag %q is not in scenario_flag_catalog", ErrInvalidInput, k)
}
}
}
if len(ruleUUIDs) > 0 {
if err := s.validateRuleUUIDs(ctx, projectID, ruleUUIDs, ruleIDsKey, delta); err != nil {
return err
}
}
return nil
}
// knownFlagKeys returns the subset of `flagKeys` that exists in the
// catalog. Used to reject writes that name unknown flags.
func (s *ScenarioFlagsService) knownFlagKeys(ctx context.Context, flagKeys []string) (map[string]struct{}, error) {
if len(flagKeys) == 0 {
return map[string]struct{}{}, nil
}
rows, err := s.db.QueryContext(ctx,
`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
pq.Array(flagKeys))
if err != nil {
return nil, fmt.Errorf("lookup flag catalog: %w", err)
}
defer rows.Close()
out := map[string]struct{}{}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out[k] = struct{}{}
}
return out, rows.Err()
}
// validateRuleUUIDs ensures every rule:<uuid> entry in the delta
// references a sequencing_rule that:
//
// 1. exists, is active, and lifecycle_state='published'
// 2. belongs to the project's current proceeding_type_id
// 3. is NOT priority='mandatory' when the value is `false` (mandatory
// rules cannot be deselected — that's a UX lie disguised as data)
func (s *ScenarioFlagsService) validateRuleUUIDs(
ctx context.Context,
projectID uuid.UUID,
ids []uuid.UUID,
keyByUUID map[string]uuid.UUID,
delta map[string]*bool,
) error {
var ptID sql.NullInt64
if err := s.db.GetContext(ctx, &ptID,
`SELECT proceeding_type_id FROM paliad.projects WHERE id = $1`,
projectID); err != nil {
return fmt.Errorf("load project proceeding_type_id: %w", err)
}
if !ptID.Valid {
return fmt.Errorf("%w: project %s has no proceeding_type_id — per-rule selection entries require one", ErrInvalidInput, projectID)
}
type row struct {
ID uuid.UUID `db:"id"`
Priority string `db:"priority"`
}
rows := []row{}
idStrs := make([]string, len(ids))
for i, id := range ids {
idStrs[i] = id.String()
}
if err := s.db.SelectContext(ctx, &rows,
`SELECT id, priority
FROM paliad.sequencing_rules
WHERE id = ANY($1::uuid[])
AND proceeding_type_id = $2
AND is_active = true
AND lifecycle_state = 'published'`,
pq.Array(idStrs), ptID.Int64); err != nil {
return fmt.Errorf("validate rule UUIDs: %w", err)
}
priorityByID := make(map[uuid.UUID]string, len(rows))
for _, r := range rows {
priorityByID[r.ID] = r.Priority
}
for key, id := range keyByUUID {
prio, ok := priorityByID[id]
if !ok {
return fmt.Errorf("%w: rule %s is not an active+published rule on the project's proceeding type", ErrInvalidInput, id)
}
val := delta[key]
if val != nil && !*val && prio == "mandatory" {
return fmt.Errorf("%w: rule %s is mandatory and cannot be deselected", ErrInvalidInput, id)
}
}
return nil
}
func (s *ScenarioFlagsService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
visible, err := s.projects.CanSee(ctx, userID, projectID)
if err != nil {
return err
}
if !visible {
return ErrNotVisible
}
return nil
}
// decodeFlagMap returns a (key → bool) map from the raw jsonb bytes.
// Stored values that aren't bool are silently dropped — they should
// never occur (the service rejects them on write) but defensive read
// avoids crashing the API if a hand-written row sneaks through.
func decodeFlagMap(raw []byte) (map[string]bool, error) {
if len(raw) == 0 {
return map[string]bool{}, nil
}
var anyMap map[string]any
if err := json.Unmarshal(raw, &anyMap); err != nil {
return nil, err
}
out := make(map[string]bool, len(anyMap))
for k, v := range anyMap {
if b, ok := v.(bool); ok {
out[k] = b
}
}
return out, nil
}