Files
paliad/internal/services/event_choice_service.go
mAi dc47ea7f43 feat(t-paliad-265): migration 129 + EventChoiceService (Slice A foundation)
m/paliad#96 — per-event-card optional choices on the Verfahrensablauf
timeline. This commit lands the schema + service layer.

Migration 129:
- paliad.project_event_choices table (project_id, submission_code,
  choice_kind ∈ {appellant, include_ccr, skip}, choice_value) with
  UNIQUE(project_id, submission_code, choice_kind) for idempotent
  re-pick, RLS via paliad.can_see_project.
- paliad.deadline_rules.choices_offered jsonb — opt-in declaration of
  which choice-kinds each rule offers. Seeded for every decision rule
  (appellant), every priority='optional' rule (skip), and the two
  Klageerwiderung rules (upc.inf.cfi.sod + de.inf.lg.erwidg) with
  include_ccr.

Live verification before authoring:
- rule_code is NULL on every decision row → submission_code is the
  join key (matches AnchorOverrides plumbing in fristenrechner.go).
- upc.inf.cfi.sod is the UPC Klageerwiderung, not upc.inf.cfi.def
  (rejected the design doc's first guess; SELECT name ILIKE
  'Klageerwiderung' confirmed).

Go service:
- models.ProjectEventChoice + DeadlineRule.ChoicesOffered.
- EventChoiceService: ListForProject / Upsert (with audit-log row to
  paliad.system_audit_log) / Delete. Pure-helper ToCalcOptionsAddendum
  + per-kind value validation + unit tests.

Design: docs/design-event-card-choices-2026-05-25.md §3 + §6.
2026-05-25 16:45:07 +02:00

273 lines
9.3 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// EventChoiceService reads and writes paliad.project_event_choices —
// per-event-card user picks scoped to a project (t-paliad-265 /
// m/paliad#96). Three choice kinds today:
//
// appellant — claimant | defendant | both | none
// include_ccr — true | false
// skip — true | false
//
// Visibility follows paliad.can_see_project (via ProjectService.CanSee).
// Audits via paliad.system_audit_log with event_type=project_event_choice.set
// (insert/update) or .deleted (delete).
//
// The CRUD surface is intentionally tight: List for a project (one read),
// Upsert one (idempotent re-pick), Delete one (kind-scoped). The
// projection engine receives the choices via ToCalcOptionsAddendum,
// which folds them into CalcOptions before Calculate runs.
type EventChoiceService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
func NewEventChoiceService(db *sqlx.DB, projects *ProjectService, users *UserService) *EventChoiceService {
return &EventChoiceService{db: db, projects: projects, users: users}
}
// Allowed choice kinds + per-kind value namespaces. Validated server-side
// before any write; the DB CHECK constraint catches the same shape but
// the early validation gives a friendlier error and short-circuits the
// transaction.
var (
allowedChoiceKinds = map[string]map[string]struct{}{
"appellant": {"claimant": {}, "defendant": {}, "both": {}, "none": {}},
"include_ccr": {"true": {}, "false": {}},
"skip": {"true": {}, "false": {}},
}
)
func validateChoice(kind, value string) error {
values, ok := allowedChoiceKinds[kind]
if !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, kind)
}
if _, ok := values[value]; !ok {
return fmt.Errorf("%w: invalid choice_value %q for kind %q", ErrInvalidInput, value, kind)
}
return nil
}
// ListForProject returns every choice row for the given project. Caller
// must hold visibility on the project.
func (s *EventChoiceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []models.ProjectEventChoice{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at
FROM paliad.project_event_choices
WHERE project_id = $1
ORDER BY submission_code, choice_kind`, projectID)
if err != nil {
return nil, fmt.Errorf("list event choices: %w", err)
}
return out, nil
}
// UpsertInput is the body shape for an upsert.
type UpsertEventChoiceInput struct {
SubmissionCode string `json:"submission_code"`
ChoiceKind string `json:"choice_kind"`
ChoiceValue string `json:"choice_value"`
}
// Upsert inserts or updates one (project, submission_code, choice_kind)
// row. Audit-log row written in the same tx.
func (s *EventChoiceService) Upsert(ctx context.Context, userID, projectID uuid.UUID, input UpsertEventChoiceInput) (*models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
if input.SubmissionCode == "" {
return nil, fmt.Errorf("%w: submission_code required", ErrInvalidInput)
}
if err := validateChoice(input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
actorEmail, err := s.actorEmail(ctx, userID)
if 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 := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.set ('||$1||','||$2||','||$3||')', true)`,
input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, fmt.Errorf("set audit reason: %w", err)
}
var row models.ProjectEventChoice
err = tx.GetContext(ctx, &row,
`INSERT INTO paliad.project_event_choices
(project_id, submission_code, choice_kind, choice_value, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $5)
ON CONFLICT (project_id, submission_code, choice_kind)
DO UPDATE SET choice_value = EXCLUDED.choice_value,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at`,
projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue, userID)
if err != nil {
return nil, fmt.Errorf("upsert event choice: %w", err)
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.set", userID, actorEmail, projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit upsert: %w", err)
}
return &row, nil
}
// Delete removes the (project, submission_code, choice_kind) row.
// Returns ErrNotVisible if the project isn't visible OR the row didn't
// exist (no leak between the two).
func (s *EventChoiceService) Delete(ctx context.Context, userID, projectID uuid.UUID, submissionCode, choiceKind string) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if submissionCode == "" || choiceKind == "" {
return fmt.Errorf("%w: submission_code + choice_kind required", ErrInvalidInput)
}
if _, ok := allowedChoiceKinds[choiceKind]; !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, choiceKind)
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.deleted ('||$1||','||$2||')', true)`,
submissionCode, choiceKind); err != nil {
return fmt.Errorf("set audit reason: %w", err)
}
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.project_event_choices
WHERE project_id = $1 AND submission_code = $2 AND choice_kind = $3`,
projectID, submissionCode, choiceKind)
if err != nil {
return fmt.Errorf("delete event choice: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotVisible
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.deleted", userID, actorEmail, projectID, submissionCode, choiceKind, ""); err != nil {
return err
}
return tx.Commit()
}
// CalcOptionsAddendum is the per-card slice of CalcOptions, built from
// the persisted choices. ProjectionService folds these into the parent
// CalcOptions before Calculate runs.
type CalcOptionsAddendum struct {
PerCardAppellant map[string]string // submission_code → appellant value
SkipRules map[string]struct{} // set of submission_code
IncludeCCRFor map[string]struct{} // set of submission_code
}
// ToCalcOptionsAddendum converts a list of choices into the calc-options
// shape. Empty input yields an addendum whose maps are non-nil but empty
// so callers can use map indexing without nil checks.
func ToCalcOptionsAddendum(choices []models.ProjectEventChoice) CalcOptionsAddendum {
out := CalcOptionsAddendum{
PerCardAppellant: map[string]string{},
SkipRules: map[string]struct{}{},
IncludeCCRFor: map[string]struct{}{},
}
for _, c := range choices {
switch c.ChoiceKind {
case "appellant":
out.PerCardAppellant[c.SubmissionCode] = c.ChoiceValue
case "skip":
if c.ChoiceValue == "true" {
out.SkipRules[c.SubmissionCode] = struct{}{}
}
case "include_ccr":
if c.ChoiceValue == "true" {
out.IncludeCCRFor[c.SubmissionCode] = struct{}{}
}
}
}
return out
}
// writeChoiceAudit inserts a project-scoped row into paliad.system_audit_log
// with the choice details in metadata. Same shape as the data-export +
// checklist audit writers.
func writeChoiceAudit(ctx context.Context, tx *sqlx.Tx, eventType string, actorID uuid.UUID, actorEmail string, projectID uuid.UUID, submissionCode, choiceKind, choiceValue string) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ($1, $2, $3, 'project', $4,
jsonb_build_object(
'submission_code', $5::text,
'choice_kind', $6::text,
'choice_value', $7::text
))`,
eventType, actorID, actorEmail, projectID, submissionCode, choiceKind, choiceValue); err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
func (s *EventChoiceService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
var email string
err := s.db.GetContext(ctx, &email,
`SELECT email FROM paliad.users WHERE id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotVisible
}
if err != nil {
return "", fmt.Errorf("lookup actor: %w", err)
}
return email, nil
}
func (s *EventChoiceService) 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
}