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.
273 lines
9.3 KiB
Go
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
|
|
}
|