feat(t-paliad-189): instance_level on project Create/Update

Phase 3 Slice 8 part 1 — wire the instance_level data field (mig 080
column, shipped in Slice 1) through the project service + handler.

  - CreateProjectInput / UpdateProjectInput gain InstanceLevel *string.
    Empty string is the explicit "clear" sentinel.
  - validateInstanceLevel + nullableInstanceLevel helpers mirror the
    OurSide pattern. Allowed values per mig 080 CHECK: 'first' |
    'appeal' | 'cassation' | NULL.
  - Service rejects bad values with ErrInvalidInput (existing handler
    error-mapping surfaces this as HTTP 400 with the standard message).
  - projectColumns SELECT now includes instance_level so reads
    populate the field; Project struct already has the field from
    Slice 1.
  - handleCreateProject accepts instance_level from the raw map; Update
    handler uses the standard JSON decoder into UpdateProjectInput.

Live-DB test exercises:
  - Create with instance_level='first' → roundtrips.
  - Update to 'appeal' → roundtrips.
  - Update to '' → NULL after the trip.
  - Update to 'supreme' → ErrInvalidInput.

The DB CHECK on mig 080 is the defence-in-depth backstop should an
SQL-direct INSERT bypass the service.
This commit is contained in:
mAi
2026-05-15 01:28:45 +02:00
parent 6f77c8354c
commit a55f45ebea
3 changed files with 154 additions and 3 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}
}