feat: UPC deadline determination — event-driven model with proceeding timeline

Full event-driven deadline determination system ported from youpc.org:

Backend:
- DetermineService: walks proceeding event tree, calculates cascading
  dates with holiday adjustment and conditional logic
- GET /api/proceeding-types/{code}/timeline — full event tree structure
- POST /api/deadlines/determine — calculate timeline with conditions
- POST /api/cases/{caseID}/deadlines/batch — batch-create deadlines
- DeadlineRule model: added is_spawn, spawn_label fields
- GetFullTimeline: recursive CTE following cross-type spawn branches
- Conditional deadlines: condition_rule_id toggles alt_duration/rule_code
  (e.g. Reply changes from RoP.029b to RoP.029a when CCR is filed)
- Seed SQL with full UPC event trees (INF, REV, CCR, APM, APP, AMD)

Frontend:
- DeadlineWizard: interactive proceeding timeline with step-by-step flow
  1. Select proceeding type (visual cards)
  2. Enter trigger event date
  3. Toggle conditional branches (CCR, Appeal, Amend)
  4. See full calculated timeline with color-coded urgency
  5. Batch-create all deadlines on a selected case
- Visual timeline tree with party icons, rule codes, duration badges
- Kept existing DeadlineCalculator as "Schnell" quick mode

Also resolved merge conflicts across 6 files (auth, router, handlers)
merging role-based permissions + audit trail features.
This commit is contained in:
m
2026-03-30 11:33:59 +02:00
parent 8e65463130
commit a89ef26ebd
14 changed files with 1642 additions and 171 deletions

View File

@@ -9,19 +9,11 @@ import (
type contextKey string
const (
<<<<<<< HEAD
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
||||||| 82878df
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
=======
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
>>>>>>> mai/pike/p0-role-based
userRoleKey contextKey = "user_role"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -41,7 +33,6 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}
<<<<<<< HEAD
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
@@ -62,8 +53,6 @@ func UserAgentFromContext(ctx context.Context) *string {
}
return nil
}
||||||| 82878df
=======
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
@@ -73,4 +62,3 @@ func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
>>>>>>> mai/pike/p0-role-based

View File

@@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx := ContextWithUserID(r.Context(), userID)
<<<<<<< HEAD
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
||||||| 82878df
// Resolve tenant and role from user_tenants
var membership struct {
TenantID uuid.UUID `db:"tenant_id"`
Role string `db:"role"`
}
err = m.db.GetContext(r.Context(), &membership,
"SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
=======
// Resolve tenant from user_tenants
var tenantID uuid.UUID
err = m.db.GetContext(r.Context(), &tenantID,
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, tenantID)
// Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For")
@@ -73,7 +43,7 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
>>>>>>> mai/knuth/p0-audit-trail-append
// Tenant and role resolution handled by TenantResolver middleware for scoped routes.
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -12,12 +12,7 @@ import (
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
<<<<<<< HEAD
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
||||||| 82878df
=======
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
>>>>>>> mai/pike/p0-role-based
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
@@ -39,6 +34,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
}
var tenantID uuid.UUID
ctx := r.Context()
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
@@ -46,38 +42,23 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return
}
<<<<<<< HEAD
// Verify user has access to this tenant
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if !hasAccess {
if role == "" {
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return
}
||||||| 82878df
=======
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
http.Error(w, "error checking tenant access", http.StatusInternalServerError)
return
}
if role == "" {
http.Error(w, "no access to this tenant", http.StatusForbidden)
return
}
>>>>>>> mai/pike/p0-role-based
tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role))
ctx = ContextWithUserRole(ctx, role)
} else {
// Default to user's first tenant (role already set by middleware)
// Default to user's first tenant
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil {
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
@@ -89,9 +70,18 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return
}
tenantID = *first
// Look up role for default tenant
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err, "user_id", userID, "tenant_id", tenantID)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
ctx = ContextWithUserRole(ctx, role)
}
ctx := ContextWithTenantID(r.Context(), tenantID)
ctx = ContextWithTenantID(ctx, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -10,57 +10,32 @@ import (
)
type mockTenantLookup struct {
<<<<<<< HEAD
tenantID *uuid.UUID
err error
hasAccess bool
accessErr error
||||||| 82878df
tenantID *uuid.UUID
err error
=======
tenantID *uuid.UUID
role string
err error
>>>>>>> mai/pike/p0-role-based
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err
}
<<<<<<< HEAD
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr
}
||||||| 82878df
=======
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.role != "" {
return m.role, m.err
}
return "associate", m.err
return m.role, m.err
}
>>>>>>> mai/pike/p0-role-based
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
<<<<<<< HEAD
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
||||||| 82878df
tr := NewTenantResolver(&mockTenantLookup{})
=======
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
>>>>>>> mai/pike/p0-role-based
var gotTenantID uuid.UUID
var gotRole string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := TenantFromContext(r.Context())
if !ok {
t.Fatal("tenant ID not in context")
}
gotTenantID = id
gotRole = UserRoleFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
@@ -77,11 +52,14 @@ func TestTenantResolver_FromHeader(t *testing.T) {
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
if gotRole != "partner" {
t.Errorf("expected role partner, got %s", gotRole)
}
}
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false})
tr := NewTenantResolver(&mockTenantLookup{role: ""})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
@@ -101,7 +79,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "associate"})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {