feat(t-paliad-265): projection engine + HTTP handlers for per-card choices
m/paliad#96 — slice A engine + slice B engine wired together (per m's Q4 bundling decision in §11 of the design doc). Engine (internal/services/fristenrechner.go): - CalcOptions gains PerCardAppellant map, SkipRules set, IncludeCCRFor set. All three keyed by paliad.deadline_rules.submission_code (same key AnchorOverrides uses). - UIDeadline gains AppellantContext (per-decision pick that propagates to descendants via parent_id chain) + ChoicesOffered (passes the jsonb through to the frontend so the caret renders). - Calculate honours all three: * IncludeCCRFor non-empty → append with_ccr to flag set before gate evaluation (v1 simplification documented in CalcOptions comment; correct for single-CCR-entry-point proceedings). * SkipRules suppression via submission_code match AND parent_id cascade (descendants suppress too — one-pass walk in sequence_order). * AppellantContext: each rule with its own per-card pick stamps its UUID; descendants inherit via parent_id lookup; "" = no override. HTTP: - /api/projects/{id}/event-choices GET / PUT / DELETE — full CRUD with visibility gate, audit-logged via paliad.system_audit_log. - POST /api/tools/fristenrechner accepts either projectId (server pulls choices from project_event_choices) OR inline perCardChoices (unbound /tools/verfahrensablauf surface). Inline wins when both. Services wiring: - EventChoiceService instantiated in cmd/server/main.go; threaded into handlers.dbServices.eventChoice.
This commit is contained in:
@@ -218,6 +218,8 @@ func main() {
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
|
||||
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
113
internal/handlers/event_choices.go
Normal file
113
internal/handlers/event_choices.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for paliad.project_event_choices (t-paliad-265 / m/paliad#96).
|
||||
//
|
||||
// Three endpoints:
|
||||
// GET /api/projects/{id}/event-choices → list
|
||||
// PUT /api/projects/{id}/event-choices → upsert one
|
||||
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
|
||||
//
|
||||
// All three gated by visibility on the project (paliad.can_see_project)
|
||||
// via EventChoiceService.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/projects/{id}/event-choices
|
||||
func handleListProjectEventChoices(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.eventChoice == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, projectID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// PUT /api/projects/{id}/event-choices — upsert one row.
|
||||
func handlePutProjectEventChoice(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.eventChoice == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
var input services.UpsertEventChoiceInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.eventChoice.Upsert(r.Context(), uid, projectID, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
|
||||
func handleDeleteProjectEventChoice(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.eventChoice == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
submissionCode := r.PathValue("submission_code")
|
||||
choiceKind := r.PathValue("choice_kind")
|
||||
if err := dbSvc.eventChoice.Delete(r.Context(), uid, projectID, submissionCode, choiceKind); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -51,6 +54,15 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
|
||||
CourtID string `json:"courtId,omitempty"`
|
||||
// t-paliad-265: per-event-card choices. Two parallel inputs:
|
||||
// - ProjectID lets the server pull persisted choices from
|
||||
// paliad.project_event_choices (project-bound /tools/fristenrechner).
|
||||
// - PerCardChoices lets the unbound /tools/verfahrensablauf
|
||||
// send an inline-CSV-decoded list straight off the URL
|
||||
// without persisting. When both are present the inline list
|
||||
// wins (what-if exploration overrides the saved state).
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -61,11 +73,42 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fold per-card choices into the CalcOptions addendum. The inline
|
||||
// PerCardChoices wins over the persisted ProjectID lookup when both
|
||||
// are non-empty.
|
||||
var addendum services.CalcOptionsAddendum
|
||||
if len(req.PerCardChoices) > 0 {
|
||||
choices := make([]models.ProjectEventChoice, 0, len(req.PerCardChoices))
|
||||
for _, c := range req.PerCardChoices {
|
||||
choices = append(choices, models.ProjectEventChoice{
|
||||
SubmissionCode: c.SubmissionCode,
|
||||
ChoiceKind: c.ChoiceKind,
|
||||
ChoiceValue: c.ChoiceValue,
|
||||
})
|
||||
}
|
||||
addendum = services.ToCalcOptionsAddendum(choices)
|
||||
} else if req.ProjectID != "" && dbSvc.eventChoice != nil {
|
||||
if pid, err := uuid.Parse(req.ProjectID); err == nil {
|
||||
if uid, ok := requireUser(w, r); ok {
|
||||
if choices, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, pid); err == nil {
|
||||
addendum = services.ToCalcOptionsAddendum(choices)
|
||||
}
|
||||
// Visibility-filtered lookup: a non-visible project
|
||||
// returns ErrNotVisible from ListForProject; in that
|
||||
// case we project without per-card overlays rather
|
||||
// than 404 — the timeline itself is non-PII data.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -106,6 +106,10 @@ type Services struct {
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -169,6 +173,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
eventChoice: svc.EventChoice,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +395,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
|
||||
|
||||
// t-paliad-265 — per-event-card choices on the Verfahrensablauf timeline.
|
||||
protected.HandleFunc("GET /api/projects/{id}/event-choices", handleListProjectEventChoices)
|
||||
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
|
||||
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
|
||||
|
||||
@@ -68,6 +68,9 @@ type dbServices struct {
|
||||
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
@@ -90,6 +90,18 @@ type UIDeadline struct {
|
||||
// court itself.
|
||||
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
||||
IsOverridden bool `json:"isOverridden,omitempty"`
|
||||
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
|
||||
// the rule so the frontend knows whether to render the per-event-card
|
||||
// caret affordance, and which choice-kinds to populate the popover
|
||||
// with. NULL / empty for rules with no choices. (t-paliad-265)
|
||||
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
||||
// AppellantContext is the per-decision appellant pick that applies
|
||||
// to descendants of the closest ancestor decision card with a
|
||||
// PerCardAppellant set. Empty when no per-card override is in
|
||||
// effect (page-level ?appellant= still applies in that case).
|
||||
// Frontend bucketer prefers this over the page-level appellant when
|
||||
// non-empty. (t-paliad-265)
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
}
|
||||
|
||||
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
||||
@@ -179,6 +191,29 @@ type CalcOptions struct {
|
||||
// Empty / nil = no override (default). Overrides apply equally to
|
||||
// the proceeding-tree and trigger-event branches.
|
||||
RuleOverrides []models.DeadlineRule
|
||||
|
||||
// Per-event-card choice overlays (t-paliad-265 / m/paliad#96).
|
||||
// Keyed by paliad.deadline_rules.submission_code — same key
|
||||
// AnchorOverrides uses.
|
||||
//
|
||||
// - PerCardAppellant: maps a decision-card's submission_code to the
|
||||
// user-picked appellant ("claimant"|"defendant"|"both"|"none").
|
||||
// The engine walks the parent chain of each rule and stamps the
|
||||
// resulting UIDeadline.AppellantContext from the closest ancestor
|
||||
// decision with a pick. The frontend bucketer then prefers the
|
||||
// per-rule context over the page-level appellant.
|
||||
// - SkipRules: set of submission_code values whose rules (and any
|
||||
// descendants) the user has opted out of for this projection.
|
||||
// Same suppression path as a failed condition_expr gate.
|
||||
// - IncludeCCRFor: set of submission_code values for rules where
|
||||
// the user opted in to the include-CCR choice (Klageerwiderung
|
||||
// cards). v1 simplification (design §4.2 #2): if non-empty,
|
||||
// "with_ccr" is appended to the flag set before gate
|
||||
// evaluation. Correct for single-CCR-entry-point proceedings
|
||||
// (UPC INF + DE LG today). Multi-CCR scope is a future expansion.
|
||||
PerCardAppellant map[string]string
|
||||
SkipRules map[string]struct{}
|
||||
IncludeCCRFor map[string]struct{}
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -233,6 +268,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
for _, f := range opts.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
// v1 simplification (design §4.2 #2, t-paliad-265): when any
|
||||
// IncludeCCRFor entry exists, we treat with_ccr as set in the flag
|
||||
// context. Correct for single-CCR-entry-point proceedings (UPC INF +
|
||||
// DE LG today). Multi-CCR scope is a future expansion that would
|
||||
// thread the include set through the gate evaluator per-rule.
|
||||
if len(opts.IncludeCCRFor) > 0 {
|
||||
flagSet["with_ccr"] = struct{}{}
|
||||
}
|
||||
|
||||
// Parse anchor overrides up-front so a malformed date errors out
|
||||
// before we start walking rules.
|
||||
@@ -329,6 +372,21 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
|
||||
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
|
||||
// for membership tests; the engine reads them but doesn't mutate.
|
||||
skipRules := opts.SkipRules
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
// skippedIDs accumulates the set of rule UUIDs whose timeline entry
|
||||
// the user has opted out of. Walking in sequence_order means a
|
||||
// child rule's parent has already been classified — so descendant
|
||||
// suppression is a one-pass parent_id lookup.
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
// appellantContext maps a rule UUID to the appellant value that
|
||||
// applies to its descendants. A rule that has its own PerCardAppellant
|
||||
// pick stamps itself with that value; a rule whose parent has a
|
||||
// context inherits it.
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false AND
|
||||
@@ -341,12 +399,49 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
continue
|
||||
}
|
||||
|
||||
// SkipRules suppression (t-paliad-265): the user has marked
|
||||
// this rule (or one of its ancestors) as "don't consider for
|
||||
// this case". Drop the row entirely AND record the rule ID so
|
||||
// descendants suppress too.
|
||||
if r.SubmissionCode != nil {
|
||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// AppellantContext propagation. A rule with its own PerCardAppellant
|
||||
// pick stamps its UUID with that value. Otherwise inherit from
|
||||
// parent if the parent had a context.
|
||||
var ctxVal string
|
||||
if r.SubmissionCode != nil {
|
||||
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal == "" && r.ParentID != nil {
|
||||
if v, ok := appellantContext[*r.ParentID]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal != "" {
|
||||
appellantContext[r.ID] = ctxVal
|
||||
}
|
||||
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
|
||||
Reference in New Issue
Block a user