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