Merge: t-paliad-189 — Fristen Phase 3 Slice 8 (wire shape swap + instance_level data + notice cards)
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker as populateCourtPickerCore,
|
||||
priorityRendering,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
@@ -269,16 +270,32 @@ async function openSaveModal() {
|
||||
// any party=court row) have no calculable date — disable + pre-uncheck
|
||||
// so users don't save the trigger-date placeholder as a real deadline.
|
||||
const isCourtDetermined = dl.isCourtSet || dl.party === "court";
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority drives
|
||||
// the save-modal pre-check + the "no save action" notice-card
|
||||
// render. priorityRendering falls back to the legacy
|
||||
// (isMandatory, isOptional) pair semantic for pre-Slice-8
|
||||
// responses; new responses carry `priority` directly.
|
||||
const pr = priorityRendering(dl);
|
||||
if (pr.hideSave) {
|
||||
// informational rules render as notice cards — no checkbox, no
|
||||
// save button, distinct visual tier. The 18 F/F filing rules
|
||||
// (Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
|
||||
// currently fall here once they're flipped to 'informational' by
|
||||
// editorial review; today they're 'recommended' so this branch
|
||||
// remains exercised only by future rule edits.
|
||||
return `<li class="frist-save-row frist-save-row--notice">
|
||||
<span class="frist-save-notice-label">${escHtml(t("deadlines.priority.informational.notice_label"))}</span>
|
||||
<span class="frist-save-title">${escHtml(dlName)}</span>
|
||||
<span class="frist-save-meta">${escHtml(t("deadlines.priority.informational"))}</span>
|
||||
</li>`;
|
||||
}
|
||||
const disabled = isCourtDetermined || !dl.dueDate;
|
||||
// Optional rules (RoP.151 cost-decision request etc.) start
|
||||
// unchecked; the user opts in. Disabled court-determined rows
|
||||
// already pre-uncheck via `disabled`. m's 2026-05-08 batch Item 2.
|
||||
const checked = !disabled && !dl.isOptional;
|
||||
const checked = !disabled && pr.preChecked;
|
||||
// Same direct-vs-indirect split as the timeline date cell —
|
||||
// chained court-set rules read as "unbestimmt" rather than
|
||||
// "wird vom Gericht bestimmt".
|
||||
const courtLabelKey = dl.isCourtSetIndirect ? "deadlines.court.indirect" : "deadlines.court.set";
|
||||
const optionalBadge = dl.isOptional && !isCourtDetermined
|
||||
const optionalBadge = (dl.priority === "optional" || dl.isOptional) && !isCourtDetermined
|
||||
? `<span class="frist-save-optional">${escHtml(t("deadlines.optional.badge"))}</span>`
|
||||
: "";
|
||||
const meta = isCourtDetermined
|
||||
|
||||
@@ -250,6 +250,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.court.indirect": "unbestimmt",
|
||||
"deadlines.optional.badge": "auf Antrag",
|
||||
"deadlines.priority.mandatory": "Pflicht",
|
||||
"deadlines.priority.recommended": "empfohlen",
|
||||
"deadlines.priority.optional": "Kann (auf Antrag)",
|
||||
"deadlines.priority.informational": "Zur Kenntnis",
|
||||
"deadlines.priority.informational.notice_label": "Hinweis",
|
||||
"project.instance_level.first": "Erste Instanz",
|
||||
"project.instance_level.appeal": "Berufung",
|
||||
"project.instance_level.cassation": "Revision",
|
||||
"project.instance_level.unset": "(nicht gesetzt)",
|
||||
"verlauf.spawn.chip": "Spawnt:",
|
||||
"verlauf.spawn.cycle_warning": "Einige proceeding-übergreifende Spawn-Regeln wurden wegen eines Zyklus übersprungen.",
|
||||
"deadlines.proceeding.selected": "Verfahren:",
|
||||
"deadlines.proceeding.reselect": "Anderes Verfahren wählen",
|
||||
"deadlines.step1.heading": "Schritt 1 — Welche Akte?",
|
||||
@@ -2599,6 +2610,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.court.indirect": "tbd",
|
||||
"deadlines.optional.badge": "on request",
|
||||
"deadlines.priority.mandatory": "Mandatory",
|
||||
"deadlines.priority.recommended": "Recommended",
|
||||
"deadlines.priority.optional": "Optional (on request)",
|
||||
"deadlines.priority.informational": "For information only",
|
||||
"deadlines.priority.informational.notice_label": "Note",
|
||||
"project.instance_level.first": "First instance",
|
||||
"project.instance_level.appeal": "Appeal",
|
||||
"project.instance_level.cassation": "Cassation",
|
||||
"project.instance_level.unset": "(unset)",
|
||||
"verlauf.spawn.chip": "Spawns into:",
|
||||
"verlauf.spawn.cycle_warning": "Some cross-proceeding spawn rules were skipped due to a cycle.",
|
||||
"deadlines.proceeding.selected": "Proceeding:",
|
||||
"deadlines.proceeding.reselect": "Choose another proceeding",
|
||||
"deadlines.step1.heading": "Step 1 — Which matter?",
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface CalculatedDeadline {
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority is now the
|
||||
// authoritative 4-way enum. The legacy isMandatory / isOptional pair
|
||||
// is still emitted by the backend (derived via wireFlagsFromPriority)
|
||||
// for one release so this code path stays buildable across the
|
||||
// cutover. Slice 9 will drop the legacy fields server-side; this
|
||||
// interface keeps them optional so the cutover lands cleanly.
|
||||
priority?: "mandatory" | "recommended" | "optional" | "informational";
|
||||
isMandatory: boolean;
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
@@ -46,6 +53,44 @@ export interface CalculatedDeadline {
|
||||
isCourtSetIndirect?: boolean;
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
// conditionExpr surfaces the jsonb gate predicate (design §2.4) so
|
||||
// the rule-editor + admin views can render the rule's gating shape.
|
||||
// Frontend save-modal logic doesn't read this in Slice 8; the rule
|
||||
// editor (Slice 11) will. Unknown shape on this side — pass-through.
|
||||
conditionExpr?: unknown;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
// uses post-Slice-8. Maps the unified Priority enum to:
|
||||
// - preChecked: whether the save-modal pre-checks the row
|
||||
// - hideSave: whether the row renders without a save button at all
|
||||
// (informational = notice card, no save action)
|
||||
// Unknown priority (or missing on legacy responses) falls back to
|
||||
// the legacy (isMandatory, isOptional) pair semantics.
|
||||
export function priorityRendering(
|
||||
d: CalculatedDeadline,
|
||||
): { preChecked: boolean; hideSave: boolean } {
|
||||
switch (d.priority) {
|
||||
case "mandatory":
|
||||
return { preChecked: true, hideSave: false };
|
||||
case "recommended":
|
||||
return { preChecked: true, hideSave: false };
|
||||
case "optional":
|
||||
return { preChecked: false, hideSave: false };
|
||||
case "informational":
|
||||
return { preChecked: false, hideSave: true };
|
||||
}
|
||||
// Legacy fallback: pre-Slice-8 backend responses without `priority`.
|
||||
// The wireFlagsFromPriority reverse is: T/F → mandatory, T/T → optional,
|
||||
// F/* → recommended. We surface the legacy pair semantic so existing
|
||||
// callers don't regress before the backend ships the new field.
|
||||
if (d.isMandatory && !d.isOptional) {
|
||||
return { preChecked: true, hideSave: false };
|
||||
}
|
||||
if (d.isMandatory && d.isOptional) {
|
||||
return { preChecked: false, hideSave: false };
|
||||
}
|
||||
return { preChecked: true, hideSave: false };
|
||||
}
|
||||
|
||||
export interface DeadlineResponse {
|
||||
|
||||
@@ -913,6 +913,11 @@ export type I18nKey =
|
||||
| "deadlines.perspective.predefined_hint"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.priority.informational"
|
||||
| "deadlines.priority.informational.notice_label"
|
||||
| "deadlines.priority.mandatory"
|
||||
| "deadlines.priority.optional"
|
||||
| "deadlines.priority.recommended"
|
||||
| "deadlines.proceeding.reselect"
|
||||
| "deadlines.proceeding.selected"
|
||||
| "deadlines.reset"
|
||||
@@ -1574,6 +1579,10 @@ export type I18nKey =
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "project.instance_level.appeal"
|
||||
| "project.instance_level.cassation"
|
||||
| "project.instance_level.first"
|
||||
| "project.instance_level.unset"
|
||||
| "projects.cancel"
|
||||
| "projects.cards.deadline_open"
|
||||
| "projects.cards.deadline_overdue"
|
||||
@@ -2059,6 +2068,8 @@ export type I18nKey =
|
||||
| "unit_role.pa"
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa"
|
||||
| "verlauf.spawn.chip"
|
||||
| "verlauf.spawn.cycle_warning"
|
||||
| "views.action.edit"
|
||||
| "views.bar.action.reset"
|
||||
| "views.bar.action.save_as_view"
|
||||
|
||||
@@ -271,6 +271,11 @@ func handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
if v, ok := raw["netdocuments_url"].(string); ok && v != "" {
|
||||
input.NetDocumentsURL = &v
|
||||
}
|
||||
if v, ok := raw["instance_level"].(string); ok {
|
||||
// Empty string is the explicit "clear" sentinel for the
|
||||
// service layer (nullableInstanceLevel writes NULL).
|
||||
input.InstanceLevel = &v
|
||||
}
|
||||
p, err := dbSvc.projects.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -150,11 +151,15 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 8 wire-shape swap: emit Priority + ConditionExpr directly;
|
||||
// keep the legacy pair populated for one release.
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
IsCourtSet: r.IsCourtSet,
|
||||
|
||||
@@ -35,12 +35,28 @@ func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayServi
|
||||
|
||||
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
|
||||
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
||||
//
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: Priority +
|
||||
// ConditionExpr are the new authoritative fields the frontend should
|
||||
// read. IsMandatory + IsOptional + (the legacy condition_flag, not
|
||||
// emitted directly on UIDeadline today) stay populated via
|
||||
// wireFlagsFromPriority for one release so the existing frontend keeps
|
||||
// working while the cutover lands. Slice 9 drops the legacy fields.
|
||||
type UIDeadline struct {
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
Party string `json:"party"`
|
||||
// Priority is the 4-way enum the rule-editor + save-modal logic
|
||||
// reads after Slice 8: 'mandatory' | 'recommended' | 'optional' |
|
||||
// 'informational'. Informational rules render as notice cards
|
||||
// (no save button, no checkbox) — the visible UX win of Phase 3
|
||||
// on today's 18 F/F rules.
|
||||
Priority string `json:"priority"`
|
||||
// IsMandatory is the LEGACY field derived from Priority via
|
||||
// wireFlagsFromPriority. Kept populated for one release so the
|
||||
// pre-Slice-8 frontend keeps working; Slice 9 drops it.
|
||||
IsMandatory bool `json:"isMandatory"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
@@ -52,9 +68,14 @@ type UIDeadline struct {
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsRootEvent bool `json:"isRootEvent"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
// IsOptional mirrors paliad.deadline_rules.is_optional. The save-
|
||||
// modal pre-unchecks these rows; the timeline still renders them
|
||||
// so the user sees what could apply.
|
||||
// ConditionExpr is the jsonb gate predicate (design §2.4 long
|
||||
// form) emitted verbatim so the rule editor (Slice 11) + admin
|
||||
// surfaces can show the rule's gating shape. NULL / empty when
|
||||
// the rule is unconditional. Frontend reads this to render the
|
||||
// "Mit Nichtigkeitswiderklage" hint chips.
|
||||
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
|
||||
// IsOptional is the LEGACY field derived from Priority via
|
||||
// wireFlagsFromPriority. Kept for one release; Slice 9 drops it.
|
||||
IsOptional bool `json:"isOptional,omitempty"`
|
||||
// IsCourtSetIndirect is true when IsCourtSet is true because the
|
||||
// rule chains off a court-determined parent (e.g. RoP.151
|
||||
@@ -240,18 +261,20 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
continue
|
||||
}
|
||||
|
||||
// Wire-compat: derive the legacy (IsMandatory, IsOptional) pair
|
||||
// from the unified priority enum so /tools/fristenrechner's
|
||||
// frontend keeps reading the same fields. Slice 8 will swap the
|
||||
// wire to emit priority directly.
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: emit Priority +
|
||||
// ConditionExpr directly. wireFlagsFromPriority still populates
|
||||
// the legacy (IsMandatory, IsOptional) pair so the pre-Slice-8
|
||||
// frontend keeps working. Slice 9 drops the legacy fields.
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
}
|
||||
if r.Code != nil {
|
||||
d.Code = *r.Code
|
||||
@@ -1079,12 +1102,18 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 8 wire-shape swap: trigger-event path also emits Priority
|
||||
// + ConditionExpr directly. Pipeline-C rules default Priority=
|
||||
// 'mandatory' (mig 085) so the legacy pair (T, F) holds.
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
IsMandatory: r.IsMandatory,
|
||||
IsOptional: r.IsOptional,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
|
||||
@@ -400,3 +400,82 @@ func TestApplyDuration_Matrix(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIDeadline_WireShape_Slice8 asserts Phase 3 Slice 8 (t-paliad-189)
|
||||
// wire-shape additivity: UIResponse.Deadlines MUST carry the new
|
||||
// `priority` + `conditionExpr` fields AND the legacy `isMandatory` +
|
||||
// `isOptional` pair (derived via wireFlagsFromPriority) for one release.
|
||||
// Slice 9 will drop the legacy fields — until then the response
|
||||
// shape is a superset.
|
||||
//
|
||||
// Live DB required so the rules.List returns real (not synthetic)
|
||||
// rules with the priority column populated by the Slice 2 backfill.
|
||||
func TestUIDeadline_WireShape_Slice8(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
holidays := NewHolidayService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
svc := NewFristenrechnerService(rules, holidays, courts)
|
||||
|
||||
resp, err := svc.Calculate(ctx, "UPC_INF", "2026-01-15", CalcOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate UPC_INF: %v", err)
|
||||
}
|
||||
if len(resp.Deadlines) == 0 {
|
||||
t.Fatal("Calculate UPC_INF returned no deadlines — seed-data missing?")
|
||||
}
|
||||
|
||||
allowed := map[string]bool{
|
||||
"mandatory": true, "recommended": true, "optional": true, "informational": true,
|
||||
}
|
||||
for _, d := range resp.Deadlines {
|
||||
if !allowed[d.Priority] {
|
||||
t.Errorf("rule %s: priority=%q not in unified enum", d.Code, d.Priority)
|
||||
}
|
||||
// Legacy-field invariant: wireFlagsFromPriority round-trip.
|
||||
// 'mandatory' → (T, F); 'optional' → (T, T); 'recommended' / 'informational' → (F, F).
|
||||
switch d.Priority {
|
||||
case "mandatory":
|
||||
if !d.IsMandatory || d.IsOptional {
|
||||
t.Errorf("rule %s: mandatory should map to (T,F), got (%v,%v)", d.Code, d.IsMandatory, d.IsOptional)
|
||||
}
|
||||
case "optional":
|
||||
if !d.IsMandatory || !d.IsOptional {
|
||||
t.Errorf("rule %s: optional should map to (T,T), got (%v,%v)", d.Code, d.IsMandatory, d.IsOptional)
|
||||
}
|
||||
case "recommended", "informational":
|
||||
if d.IsMandatory || d.IsOptional {
|
||||
t.Errorf("rule %s: %s should map to (F,F), got (%v,%v)",
|
||||
d.Code, d.Priority, d.IsMandatory, d.IsOptional)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At least one rule should carry a populated conditionExpr (the
|
||||
// 17 with_ccr / with_amend / with_cci rules mig 084 translated).
|
||||
// Spot-check that the field actually serialises as jsonb (non-empty
|
||||
// bytes on at least one row).
|
||||
var sawConditionExpr bool
|
||||
for _, d := range resp.Deadlines {
|
||||
if len(d.ConditionExpr) > 0 && string(d.ConditionExpr) != "null" {
|
||||
sawConditionExpr = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawConditionExpr {
|
||||
t.Logf("warning: no UPC_INF rule had conditionExpr populated — verify mig 084 ran")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
|
||||
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number, matter_number,
|
||||
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
|
||||
proceeding_type_id, our_side, counterclaim_of, metadata, ai_summary, created_at, updated_at`
|
||||
proceeding_type_id, our_side, counterclaim_of, instance_level, metadata, ai_summary,
|
||||
created_at, updated_at`
|
||||
|
||||
// CreateProjectInput is the payload for Create.
|
||||
type CreateProjectInput struct {
|
||||
@@ -129,6 +130,14 @@ type CreateProjectInput struct {
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
// InstanceLevel is the procedural instance the project sits at:
|
||||
// 'first' (default once the picker UI lands) | 'appeal' | 'cassation'.
|
||||
// NULL = unset. Phase 3 Slice 8 (t-paliad-189, design §7) — the
|
||||
// SmartTimeline + calculator combine this with proceeding_code +
|
||||
// jurisdiction to pick the effective rule corpus (DE_INF + appeal →
|
||||
// DE_INF_OLG, etc.). Validated against the mig 080 CHECK on the
|
||||
// column; service surfaces ErrInvalidInput on a bad value.
|
||||
InstanceLevel *string `json:"instance_level,omitempty"`
|
||||
|
||||
// CounterclaimOf marks this project as a CCR sub-project filed
|
||||
// against the referenced parent project (t-paliad-174 Slice 3).
|
||||
@@ -160,6 +169,10 @@ type UpdateProjectInput struct {
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
// InstanceLevel — see CreateProjectInput.InstanceLevel. UPDATE
|
||||
// path: caller passes a pointer to the new value to swap; pass
|
||||
// a pointer to "" to clear (NULL the column).
|
||||
InstanceLevel *string `json:"instance_level,omitempty"`
|
||||
}
|
||||
|
||||
// ListFilter narrows List results. Zero-value → no filter.
|
||||
@@ -843,15 +856,20 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.InstanceLevel != nil {
|
||||
if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number,
|
||||
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id, our_side, counterclaim_of,
|
||||
metadata, created_at, updated_at)
|
||||
instance_level, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22, '{}'::jsonb, $23, $23)`,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, '{}'::jsonb, $24, $24)`,
|
||||
id, input.Type, input.ParentID,
|
||||
input.Title, input.Reference, input.Description, status,
|
||||
userID,
|
||||
@@ -861,6 +879,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
input.Court, input.CaseNumber, input.ProceedingTypeID,
|
||||
nullableOurSide(input.OurSide),
|
||||
input.CounterclaimOf,
|
||||
nullableInstanceLevel(input.InstanceLevel),
|
||||
now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert project: %w", err)
|
||||
@@ -1003,6 +1022,12 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
}
|
||||
appendSet("our_side", nullableOurSide(input.OurSide))
|
||||
}
|
||||
if input.InstanceLevel != nil {
|
||||
if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("instance_level", nullableInstanceLevel(input.InstanceLevel))
|
||||
}
|
||||
if typeChanged {
|
||||
for _, col := range typeSpecificColumns(current.Type) {
|
||||
appendSet(col, nil)
|
||||
@@ -1883,6 +1908,36 @@ func validateOurSide(s string) error {
|
||||
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
|
||||
}
|
||||
|
||||
// validateInstanceLevel checks the procedural-instance enum (Phase 3
|
||||
// Slice 8, t-paliad-189, design §7). Empty string clears the column;
|
||||
// the three named values map to the rule-corpus ladder DE_INF →
|
||||
// DE_INF_OLG → DE_INF_BGH that the SmartTimeline will surface in a
|
||||
// follow-up calculator slice. The DB-level CHECK on mig 080 enforces
|
||||
// the same set; this validation gives a clearer error than letting
|
||||
// the trigger fire.
|
||||
func validateInstanceLevel(s string) error {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "", "first", "appeal", "cassation":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: invalid instance_level %q (allowed: first | appeal | cassation | <empty>)",
|
||||
ErrInvalidInput, s)
|
||||
}
|
||||
|
||||
// nullableInstanceLevel returns nil for an empty / whitespace value so
|
||||
// the SQL driver writes NULL, otherwise the trimmed string. Mirrors
|
||||
// nullableOurSide.
|
||||
func nullableInstanceLevel(p *string) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
s := strings.TrimSpace(*p)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// nullableOurSide returns nil for an empty / whitespace value so the
|
||||
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
|
||||
// Update payload contract: empty string from the form clears the
|
||||
|
||||
@@ -146,3 +146,94 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
|
||||
t.Error("raw INSERT with litigation-category proceeding_type_id should have raised; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8
|
||||
// (t-paliad-189) instance_level data path: Create + Update both accept
|
||||
// the four allowed shapes (first / appeal / cassation / NULL) and reject
|
||||
// anything else with ErrInvalidInput. The DB CHECK from mig 080
|
||||
// (Slice 1) is the defence-in-depth backstop; the service-layer
|
||||
// validation provides a clearer error to the handler.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestProjectService_InstanceLevel_Roundtrip(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
users := NewUserService(pool)
|
||||
svc := NewProjectService(pool, users)
|
||||
|
||||
userID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'slice8-instance-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
||||
VALUES ($1, 'slice8-instance-test@hlc.com', 'Slice8 Test', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
// Create with instance_level='first'.
|
||||
first := "first"
|
||||
created, err := svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Slice 8 — instance_level first",
|
||||
InstanceLevel: &first,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create with instance_level=first: %v", err)
|
||||
}
|
||||
if created.InstanceLevel == nil || *created.InstanceLevel != "first" {
|
||||
t.Errorf("created InstanceLevel = %v, want 'first'", created.InstanceLevel)
|
||||
}
|
||||
|
||||
// Update to 'appeal'.
|
||||
appeal := "appeal"
|
||||
updated, err := svc.Update(ctx, userID, created.ID, UpdateProjectInput{InstanceLevel: &appeal})
|
||||
if err != nil {
|
||||
t.Fatalf("Update to appeal: %v", err)
|
||||
}
|
||||
if updated.InstanceLevel == nil || *updated.InstanceLevel != "appeal" {
|
||||
t.Errorf("updated InstanceLevel = %v, want 'appeal'", updated.InstanceLevel)
|
||||
}
|
||||
|
||||
// Update to '' (clear).
|
||||
clear := ""
|
||||
cleared, err := svc.Update(ctx, userID, created.ID, UpdateProjectInput{InstanceLevel: &clear})
|
||||
if err != nil {
|
||||
t.Fatalf("Update clear: %v", err)
|
||||
}
|
||||
if cleared.InstanceLevel != nil {
|
||||
t.Errorf("cleared InstanceLevel = %v, want nil", cleared.InstanceLevel)
|
||||
}
|
||||
|
||||
// Invalid value → ErrInvalidInput.
|
||||
bogus := "supreme"
|
||||
_, err = svc.Update(ctx, userID, created.ID, UpdateProjectInput{InstanceLevel: &bogus})
|
||||
if err == nil {
|
||||
t.Error("instance_level=supreme should fail; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("want ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user