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.
376 lines
12 KiB
Go
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
|
|
}
|