diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index c2aeaef..35bfa8c 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -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 diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 02428c6..aeb158c 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -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)) }) } diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go index 0dab57f..49d11ba 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -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)) }) } diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go index d0300c2..e03fed7 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -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) { diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index e1a2a39..1ff7629 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -198,18 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { return } -<<<<<<< HEAD - if err := h.deadlines.Delete(tenantID, deadlineID); err != nil { + if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil { writeError(w, http.StatusNotFound, "deadline not found") -||||||| 82878df - err = h.deadlines.Delete(tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) -======= - err = h.deadlines.Delete(r.Context(), tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) ->>>>>>> mai/knuth/p0-audit-trail-append return } diff --git a/backend/internal/handlers/determine.go b/backend/internal/handlers/determine.go new file mode 100644 index 0000000..b8bcc3b --- /dev/null +++ b/backend/internal/handlers/determine.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +// DetermineHandlers holds handlers for deadline determination endpoints +type DetermineHandlers struct { + determine *services.DetermineService + deadlines *services.DeadlineService +} + +// NewDetermineHandlers creates determine handlers +func NewDetermineHandlers(determine *services.DetermineService, deadlines *services.DeadlineService) *DetermineHandlers { + return &DetermineHandlers{determine: determine, deadlines: deadlines} +} + +// GetTimeline handles GET /api/proceeding-types/{code}/timeline +// Returns the full event tree for a proceeding type (no date calculations) +func (h *DetermineHandlers) GetTimeline(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if code == "" { + writeError(w, http.StatusBadRequest, "proceeding type code required") + return + } + + timeline, pt, err := h.determine.GetTimeline(code) + if err != nil { + writeError(w, http.StatusNotFound, "proceeding type not found") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "proceeding_type": pt, + "timeline": timeline, + }) +} + +// Determine handles POST /api/deadlines/determine +// Calculates the full timeline with cascading dates and conditional logic +func (h *DetermineHandlers) Determine(w http.ResponseWriter, r *http.Request) { + var req services.DetermineRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.ProceedingType == "" || req.TriggerEventDate == "" { + writeError(w, http.StatusBadRequest, "proceeding_type and trigger_event_date are required") + return + } + + resp, err := h.determine.Determine(req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, resp) +} + +// BatchCreate handles POST /api/cases/{caseID}/deadlines/batch +// Creates multiple deadlines on a case from determined timeline +func (h *DetermineHandlers) BatchCreate(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + caseID, err := parsePathUUID(r, "caseID") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + var req struct { + Deadlines []struct { + Title string `json:"title"` + DueDate string `json:"due_date"` + OriginalDueDate *string `json:"original_due_date,omitempty"` + RuleID *uuid.UUID `json:"rule_id,omitempty"` + RuleCode *string `json:"rule_code,omitempty"` + Notes *string `json:"notes,omitempty"` + } `json:"deadlines"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(req.Deadlines) == 0 { + writeError(w, http.StatusBadRequest, "at least one deadline is required") + return + } + + var created int + for _, d := range req.Deadlines { + if d.Title == "" || d.DueDate == "" { + continue + } + input := services.CreateDeadlineInput{ + CaseID: caseID, + Title: d.Title, + DueDate: d.DueDate, + Source: "determined", + RuleID: d.RuleID, + Notes: d.Notes, + } + _, err := h.deadlines.Create(r.Context(), tenantID, input) + if err != nil { + internalError(w, "failed to create deadline", err) + return + } + created++ + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "created": created, + }) +} diff --git a/backend/internal/models/deadline_rule.go b/backend/internal/models/deadline_rule.go index a582e4b..9c62076 100644 --- a/backend/internal/models/deadline_rule.go +++ b/backend/internal/models/deadline_rule.go @@ -26,6 +26,8 @@ type DeadlineRule struct { AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` + IsSpawn bool `db:"is_spawn" json:"is_spawn"` + SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` IsActive bool `db:"is_active" json:"is_active"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 84b7f8e..8c14a30 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -28,15 +28,10 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se deadlineSvc := services.NewDeadlineService(db, auditSvc) deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) + determineSvc := services.NewDetermineService(db, calculator) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) -<<<<<<< HEAD documentSvc := services.NewDocumentService(db, storageCli, auditSvc) -||||||| 82878df - documentSvc := services.NewDocumentService(db, storageCli) -======= - documentSvc := services.NewDocumentService(db, storageCli) assignmentSvc := services.NewCaseAssignmentService(db) ->>>>>>> mai/pike/p0-role-based // AI service (optional — only if API key is configured) var aiH *handlers.AIHandler @@ -66,6 +61,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se deadlineH := handlers.NewDeadlineHandlers(deadlineSvc) ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) + determineH := handlers.NewDetermineHandlers(determineSvc, deadlineSvc) dashboardH := handlers.NewDashboardHandler(dashboardSvc) noteH := handlers.NewNoteHandler(noteSvc) eventH := handlers.NewCaseEventHandler(db) @@ -112,7 +108,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("GET /api/cases", caseH.List) scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create)) scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) - scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler + scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete)) // Parties — same access as case editing @@ -138,6 +134,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Deadline calculator — all can use scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) + // Deadline determination — full timeline calculation with conditions + scoped.HandleFunc("GET /api/proceeding-types/{code}/timeline", determineH.GetTimeline) + scoped.HandleFunc("POST /api/deadlines/determine", determineH.Determine) + scoped.HandleFunc("POST /api/cases/{caseID}/deadlines/batch", perm(auth.PermManageDeadlines, determineH.BatchCreate)) + // Appointments — all can manage (PermManageAppointments granted to all) scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get) scoped.HandleFunc("GET /api/appointments", apptH.List) @@ -162,21 +163,15 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Dashboard — all can view scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) -<<<<<<< HEAD // Audit log scoped.HandleFunc("GET /api/audit-log", auditH.List) - // Documents -||||||| 82878df - // Documents -======= // Documents — all can upload, delete checked in handler (own vs all) ->>>>>>> mai/pike/p0-role-based scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) - scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler + scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // AI endpoints (rate limited: 5 req/min burst 10 per IP) if aiH != nil { @@ -185,7 +180,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) } -<<<<<<< HEAD // Notifications if notifH != nil { scoped.HandleFunc("GET /api/notifications", notifH.List) @@ -196,12 +190,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences) } - // CalDAV sync endpoints -||||||| 82878df - // CalDAV sync endpoints -======= // CalDAV sync endpoints — settings permission required ->>>>>>> mai/pike/p0-role-based if calDAVSvc != nil { calDAVH := handlers.NewCalDAVHandler(calDAVSvc) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) diff --git a/backend/internal/services/deadline_rule_service.go b/backend/internal/services/deadline_rule_service.go index ae6ee1d..a2ccefb 100644 --- a/backend/internal/services/deadline_rule_service.go +++ b/backend/internal/services/deadline_rule_service.go @@ -8,6 +8,12 @@ import ( "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" ) +const ruleColumns = `id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + timing, rule_code, deadline_notes, sequence_order, condition_rule_id, + alt_duration_value, alt_duration_unit, alt_rule_code, + is_spawn, spawn_label, is_active, created_at, updated_at` + // DeadlineRuleService handles deadline rule queries type DeadlineRuleService struct { db *sqlx.DB @@ -25,21 +31,13 @@ func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule if proceedingTypeID != nil { err = s.db.Select(&rules, - `SELECT id, proceeding_type_id, parent_id, code, name, description, - primary_party, event_type, is_mandatory, duration_value, duration_unit, - timing, rule_code, deadline_notes, sequence_order, condition_rule_id, - alt_duration_value, alt_duration_unit, alt_rule_code, is_active, - created_at, updated_at + `SELECT `+ruleColumns+` FROM deadline_rules WHERE proceeding_type_id = $1 AND is_active = true ORDER BY sequence_order`, *proceedingTypeID) } else { err = s.db.Select(&rules, - `SELECT id, proceeding_type_id, parent_id, code, name, description, - primary_party, event_type, is_mandatory, duration_value, duration_unit, - timing, rule_code, deadline_notes, sequence_order, condition_rule_id, - alt_duration_value, alt_duration_unit, alt_rule_code, is_active, - created_at, updated_at + `SELECT `+ruleColumns+` FROM deadline_rules WHERE is_active = true ORDER BY proceeding_type_id, sequence_order`) @@ -72,11 +70,7 @@ func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTree // Get all rules for this proceeding type var rules []models.DeadlineRule err = s.db.Select(&rules, - `SELECT id, proceeding_type_id, parent_id, code, name, description, - primary_party, event_type, is_mandatory, duration_value, duration_unit, - timing, rule_code, deadline_notes, sequence_order, condition_rule_id, - alt_duration_value, alt_duration_unit, alt_rule_code, is_active, - created_at, updated_at + `SELECT `+ruleColumns+` FROM deadline_rules WHERE proceeding_type_id = $1 AND is_active = true ORDER BY sequence_order`, pt.ID) @@ -87,6 +81,36 @@ func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTree return buildTree(rules), nil } +// GetFullTimeline returns the full event tree for a proceeding type using a recursive CTE. +// Unlike GetRuleTree, this follows parent_id across proceeding types (includes cross-type spawns). +func (s *DeadlineRuleService) GetFullTimeline(proceedingTypeCode string) ([]models.DeadlineRule, *models.ProceedingType, error) { + var pt models.ProceedingType + err := s.db.Get(&pt, + `SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active + FROM proceeding_types + WHERE code = $1 AND is_active = true`, proceedingTypeCode) + if err != nil { + return nil, nil, fmt.Errorf("resolving proceeding type %q: %w", proceedingTypeCode, err) + } + + var rules []models.DeadlineRule + err = s.db.Select(&rules, ` + WITH RECURSIVE tree AS ( + SELECT * FROM deadline_rules + WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true + UNION ALL + SELECT dr.* FROM deadline_rules dr + JOIN tree t ON dr.parent_id = t.id + WHERE dr.is_active = true + ) + SELECT `+ruleColumns+` FROM tree ORDER BY sequence_order`, pt.ID) + if err != nil { + return nil, nil, fmt.Errorf("fetching timeline for type %q: %w", proceedingTypeCode, err) + } + + return rules, &pt, nil +} + // GetByIDs returns deadline rules by their IDs func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) { if len(ids) == 0 { @@ -94,11 +118,7 @@ func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, err } query, args, err := sqlx.In( - `SELECT id, proceeding_type_id, parent_id, code, name, description, - primary_party, event_type, is_mandatory, duration_value, duration_unit, - timing, rule_code, deadline_notes, sequence_order, condition_rule_id, - alt_duration_value, alt_duration_unit, alt_rule_code, is_active, - created_at, updated_at + `SELECT `+ruleColumns+` FROM deadline_rules WHERE id IN (?) AND is_active = true ORDER BY sequence_order`, ids) @@ -119,11 +139,7 @@ func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, err func (s *DeadlineRuleService) GetRulesForProceedingType(proceedingTypeID int) ([]models.DeadlineRule, error) { var rules []models.DeadlineRule err := s.db.Select(&rules, - `SELECT id, proceeding_type_id, parent_id, code, name, description, - primary_party, event_type, is_mandatory, duration_value, duration_unit, - timing, rule_code, deadline_notes, sequence_order, condition_rule_id, - alt_duration_value, alt_duration_unit, alt_rule_code, is_active, - created_at, updated_at + `SELECT `+ruleColumns+` FROM deadline_rules WHERE proceeding_type_id = $1 AND is_active = true ORDER BY sequence_order`, proceedingTypeID) diff --git a/backend/internal/services/determine_service.go b/backend/internal/services/determine_service.go new file mode 100644 index 0000000..1f6cb16 --- /dev/null +++ b/backend/internal/services/determine_service.go @@ -0,0 +1,236 @@ +package services + +import ( + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +// DetermineService handles event-driven deadline determination. +// It walks the proceeding event tree and calculates cascading dates. +type DetermineService struct { + rules *DeadlineRuleService + calculator *DeadlineCalculator +} + +// NewDetermineService creates a new determine service +func NewDetermineService(db *sqlx.DB, calculator *DeadlineCalculator) *DetermineService { + return &DetermineService{ + rules: NewDeadlineRuleService(db), + calculator: calculator, + } +} + +// TimelineEvent represents a calculated event in the proceeding timeline +type TimelineEvent struct { + ID string `json:"id"` + Code string `json:"code,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + PrimaryParty string `json:"primary_party,omitempty"` + EventType string `json:"event_type,omitempty"` + IsMandatory bool `json:"is_mandatory"` + DurationValue int `json:"duration_value"` + DurationUnit string `json:"duration_unit"` + RuleCode string `json:"rule_code,omitempty"` + DeadlineNotes string `json:"deadline_notes,omitempty"` + IsSpawn bool `json:"is_spawn"` + SpawnLabel string `json:"spawn_label,omitempty"` + HasCondition bool `json:"has_condition"` + ConditionRuleID string `json:"condition_rule_id,omitempty"` + AltRuleCode string `json:"alt_rule_code,omitempty"` + AltDurationValue *int `json:"alt_duration_value,omitempty"` + AltDurationUnit string `json:"alt_duration_unit,omitempty"` + Date string `json:"date,omitempty"` + OriginalDate string `json:"original_date,omitempty"` + WasAdjusted bool `json:"was_adjusted"` + Children []TimelineEvent `json:"children,omitempty"` +} + +// DetermineRequest is the input for POST /api/deadlines/determine +type DetermineRequest struct { + ProceedingType string `json:"proceeding_type"` + TriggerEventDate string `json:"trigger_event_date"` + Conditions map[string]bool `json:"conditions"` +} + +// DetermineResponse is the output of the determine endpoint +type DetermineResponse struct { + ProceedingType string `json:"proceeding_type"` + ProceedingName string `json:"proceeding_name"` + ProceedingColor string `json:"proceeding_color"` + TriggerDate string `json:"trigger_event_date"` + Timeline []TimelineEvent `json:"timeline"` + TotalDeadlines int `json:"total_deadlines"` +} + +// GetTimeline returns the proceeding event tree (without date calculations) +func (s *DetermineService) GetTimeline(proceedingTypeCode string) ([]TimelineEvent, *models.ProceedingType, error) { + rules, pt, err := s.rules.GetFullTimeline(proceedingTypeCode) + if err != nil { + return nil, nil, err + } + + tree := buildTimelineTree(rules) + return tree, pt, nil +} + +// Determine calculates the full timeline with cascading dates +func (s *DetermineService) Determine(req DetermineRequest) (*DetermineResponse, error) { + timeline, pt, err := s.GetTimeline(req.ProceedingType) + if err != nil { + return nil, fmt.Errorf("loading timeline: %w", err) + } + + triggerDate, err := time.Parse("2006-01-02", req.TriggerEventDate) + if err != nil { + return nil, fmt.Errorf("invalid trigger_event_date: %w", err) + } + + conditions := req.Conditions + if conditions == nil { + conditions = make(map[string]bool) + } + + total := s.calculateDates(timeline, triggerDate, conditions) + + return &DetermineResponse{ + ProceedingType: pt.Code, + ProceedingName: pt.Name, + ProceedingColor: pt.DefaultColor, + TriggerDate: req.TriggerEventDate, + Timeline: timeline, + TotalDeadlines: total, + }, nil +} + +// calculateDates walks the tree and calculates dates for each node +func (s *DetermineService) calculateDates(events []TimelineEvent, parentDate time.Time, conditions map[string]bool) int { + total := 0 + for i := range events { + ev := &events[i] + + // Skip inactive spawns: if this is a spawn node and conditions don't include it, skip + if ev.IsSpawn && !conditions[ev.ID] { + continue + } + + durationValue := ev.DurationValue + durationUnit := ev.DurationUnit + ruleCode := ev.RuleCode + + // Apply conditional logic + if ev.HasCondition && ev.ConditionRuleID != "" { + if conditions[ev.ConditionRuleID] { + if ev.AltDurationValue != nil { + durationValue = *ev.AltDurationValue + } + if ev.AltDurationUnit != "" { + durationUnit = ev.AltDurationUnit + } + if ev.AltRuleCode != "" { + ruleCode = ev.AltRuleCode + } + } + } + + // Calculate this node's date + if durationValue > 0 { + rule := models.DeadlineRule{ + DurationValue: durationValue, + DurationUnit: durationUnit, + } + adjusted, original, wasAdjusted := s.calculator.CalculateEndDate(parentDate, rule) + ev.Date = adjusted.Format("2006-01-02") + ev.OriginalDate = original.Format("2006-01-02") + ev.WasAdjusted = wasAdjusted + } else { + ev.Date = parentDate.Format("2006-01-02") + ev.OriginalDate = parentDate.Format("2006-01-02") + } + + ev.RuleCode = ruleCode + total++ + + // Recurse: children's dates cascade from this node's date + if len(ev.Children) > 0 { + childDate, _ := time.Parse("2006-01-02", ev.Date) + total += s.calculateDates(ev.Children, childDate, conditions) + } + } + return total +} + +// buildTimelineTree converts flat rules to a tree of TimelineEvents +func buildTimelineTree(rules []models.DeadlineRule) []TimelineEvent { + nodeMap := make(map[string]*TimelineEvent, len(rules)) + var roots []TimelineEvent + + // Create event nodes + for _, r := range rules { + ev := ruleToEvent(r) + nodeMap[r.ID.String()] = &ev + } + + // Build tree by parent_id + for _, r := range rules { + ev := nodeMap[r.ID.String()] + if r.ParentID != nil { + parentKey := r.ParentID.String() + if parent, ok := nodeMap[parentKey]; ok { + parent.Children = append(parent.Children, *ev) + continue + } + } + roots = append(roots, *ev) + } + + return roots +} + +func ruleToEvent(r models.DeadlineRule) TimelineEvent { + ev := TimelineEvent{ + ID: r.ID.String(), + Name: r.Name, + IsMandatory: r.IsMandatory, + DurationValue: r.DurationValue, + DurationUnit: r.DurationUnit, + IsSpawn: r.IsSpawn, + HasCondition: r.ConditionRuleID != nil, + } + if r.Code != nil { + ev.Code = *r.Code + } + if r.Description != nil { + ev.Description = *r.Description + } + if r.PrimaryParty != nil { + ev.PrimaryParty = *r.PrimaryParty + } + if r.EventType != nil { + ev.EventType = *r.EventType + } + if r.RuleCode != nil { + ev.RuleCode = *r.RuleCode + } + if r.DeadlineNotes != nil { + ev.DeadlineNotes = *r.DeadlineNotes + } + if r.SpawnLabel != nil { + ev.SpawnLabel = *r.SpawnLabel + } + if r.ConditionRuleID != nil { + ev.ConditionRuleID = r.ConditionRuleID.String() + } + if r.AltRuleCode != nil { + ev.AltRuleCode = *r.AltRuleCode + } + ev.AltDurationValue = r.AltDurationValue + if r.AltDurationUnit != nil { + ev.AltDurationUnit = *r.AltDurationUnit + } + return ev +} diff --git a/backend/seed/seed_upc_timeline.sql b/backend/seed/seed_upc_timeline.sql new file mode 100644 index 0000000..de9f234 --- /dev/null +++ b/backend/seed/seed_upc_timeline.sql @@ -0,0 +1,466 @@ +-- UPC Proceeding Timeline: Full event tree with conditional deadlines +-- Ported from youpc.org migrations 039 + 040 +-- Run against kanzlai schema in flexsiebels Supabase instance + +-- ======================================== +-- 1. Add is_spawn + spawn_label columns +-- ======================================== +ALTER TABLE deadline_rules + ADD COLUMN IF NOT EXISTS is_spawn BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS spawn_label TEXT; + +-- ======================================== +-- 2. Clear existing UPC rules (fresh seed) +-- ======================================== +DELETE FROM deadline_rules WHERE proceeding_type_id IN ( + SELECT id FROM proceeding_types WHERE code IN ('INF', 'REV', 'CCR', 'APM', 'APP', 'AMD') +); + +-- ======================================== +-- 3. Ensure all proceeding types exist +-- ======================================== +INSERT INTO proceeding_types (code, name, description, is_active, sort_order, default_color) +VALUES + ('INF', 'Infringement', 'Patent infringement proceedings', true, 1, '#3b82f6'), + ('REV', 'Revocation', 'Standalone revocation proceedings', true, 2, '#ef4444'), + ('CCR', 'Counterclaim for Revocation', 'Counterclaim for revocation within infringement', true, 3, '#ef4444'), + ('APM', 'Provisional Measures', 'Application for preliminary injunction', true, 4, '#f59e0b'), + ('APP', 'Appeal', 'Appeal to the Court of Appeal', true, 5, '#8b5cf6'), + ('AMD', 'Application to Amend Patent', 'Sub-proceeding for patent amendment during revocation', true, 6, '#10b981') +ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + default_color = EXCLUDED.default_color, + sort_order = EXCLUDED.sort_order, + is_active = EXCLUDED.is_active; + +-- ======================================== +-- 4. Seed all proceeding events +-- ======================================== +DO $$ +DECLARE + v_inf INTEGER; + v_rev INTEGER; + v_ccr INTEGER; + v_apm INTEGER; + v_app INTEGER; + v_amd INTEGER; + -- INF event IDs + v_inf_soc UUID; + v_inf_sod UUID; + v_inf_reply UUID; + v_inf_rejoin UUID; + v_inf_interim UUID; + v_inf_oral UUID; + v_inf_decision UUID; + v_inf_prelim UUID; + -- CCR event IDs + v_ccr_root UUID; + v_ccr_defence UUID; + v_ccr_reply UUID; + v_ccr_rejoin UUID; + v_ccr_interim UUID; + v_ccr_oral UUID; + v_ccr_decision UUID; + -- REV event IDs + v_rev_app UUID; + v_rev_defence UUID; + v_rev_reply UUID; + v_rev_rejoin UUID; + v_rev_interim UUID; + v_rev_oral UUID; + v_rev_decision UUID; + -- PI event IDs + v_pi_app UUID; + v_pi_resp UUID; + v_pi_oral UUID; + -- APP event IDs + v_app_notice UUID; + v_app_grounds UUID; + v_app_response UUID; + v_app_oral UUID; +BEGIN + SELECT id INTO v_inf FROM proceeding_types WHERE code = 'INF'; + SELECT id INTO v_rev FROM proceeding_types WHERE code = 'REV'; + SELECT id INTO v_ccr FROM proceeding_types WHERE code = 'CCR'; + SELECT id INTO v_apm FROM proceeding_types WHERE code = 'APM'; + SELECT id INTO v_app FROM proceeding_types WHERE code = 'APP'; + SELECT id INTO v_amd FROM proceeding_types WHERE code = 'AMD'; + + -- ======================================== + -- INFRINGEMENT PROCEEDINGS + -- ======================================== + + -- Root: Statement of Claim + v_inf_soc := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_soc, v_inf, NULL, 'inf.soc', 'Statement of Claim', + 'Claimant files the statement of claim with the Registry', + 'claimant', 'filing', true, 0, 'months', NULL, NULL, false, NULL, 0, true); + + -- Preliminary Objection (from SoC) + v_inf_prelim := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_prelim, v_inf, v_inf_soc, 'inf.prelim', 'Preliminary Objection', + 'Defendant raises preliminary objection (jurisdiction, admissibility)', + 'defendant', 'filing', false, 1, 'months', 'R.19', + 'Rarely triggers separate decision; usually decided with main case', + false, NULL, 1, true); + + -- Statement of Defence (from SoC) + v_inf_sod := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_sod, v_inf, v_inf_soc, 'inf.sod', 'Statement of Defence', + 'Defendant files the statement of defence', + 'defendant', 'filing', true, 3, 'months', 'RoP.023', NULL, + false, NULL, 2, true); + + -- Reply to Defence (from SoD) — CONDITIONAL: rule code changes if CCR + v_inf_reply := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_reply, v_inf, v_inf_sod, 'inf.reply', 'Reply to Defence', + 'Claimant''s reply to the statement of defence (includes Defence to Counterclaim if CCR active)', + 'claimant', 'filing', true, 2, 'months', 'RoP.029b', NULL, + false, NULL, 1, true); + + -- Rejoinder (from Reply) — CONDITIONAL: duration changes if CCR + v_inf_rejoin := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_rejoin, v_inf, v_inf_reply, 'inf.rejoin', 'Rejoinder', + 'Defendant''s rejoinder to the reply', + 'defendant', 'filing', true, 1, 'months', 'RoP.029c', NULL, + false, NULL, 0, true); + + -- Interim Conference + v_inf_interim := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_interim, v_inf, v_inf_rejoin, 'inf.interim', 'Interim Conference', + 'Interim conference with the judge-rapporteur', + 'court', 'hearing', true, 0, 'months', NULL, NULL, false, NULL, 0, true); + + -- Oral Hearing + v_inf_oral := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_oral, v_inf, v_inf_interim, 'inf.oral', 'Oral Hearing', + 'Oral hearing before the panel', + 'court', 'hearing', true, 0, 'months', NULL, NULL, false, NULL, 0, true); + + -- Decision + v_inf_decision := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_inf_decision, v_inf, v_inf_oral, 'inf.decision', 'Decision', + 'Panel delivers its decision', + 'court', 'decision', true, 0, 'months', NULL, NULL, false, NULL, 0, true); + + -- Appeal (spawn from Decision — cross-type to APP) + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (gen_random_uuid(), v_app, v_inf_decision, 'inf.appeal', 'Appeal', + 'Appeal against infringement decision to Court of Appeal', + 'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL, + true, 'Appeal filed', 0, true); + + -- ======================================== + -- COUNTERCLAIM FOR REVOCATION (spawn from SoD) + -- ======================================== + + v_ccr_root := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_ccr_root, v_ccr, v_inf_sod, 'ccr.counterclaim', 'Counterclaim for Revocation', + 'Defendant files counterclaim challenging patent validity (included in SoD)', + 'defendant', 'filing', true, 0, 'months', NULL, NULL, + true, 'Includes counterclaim for revocation', 0, true); + + -- Defence to Counterclaim + v_ccr_defence := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_ccr_defence, v_ccr, v_ccr_root, 'ccr.defence', 'Defence to Counterclaim', + 'Patent proprietor files defence to revocation counterclaim', + 'claimant', 'filing', true, 3, 'months', 'RoP.050', NULL, + false, NULL, 0, true); + + -- Reply in CCR + v_ccr_reply := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_ccr_reply, v_ccr, v_ccr_defence, 'ccr.reply', 'Reply in CCR', + 'Reply in the counterclaim for revocation', + 'defendant', 'filing', true, 2, 'months', NULL, + 'Timing overlaps with infringement Rejoinder', + false, NULL, 1, true); + + -- Rejoinder in CCR + v_ccr_rejoin := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_ccr_rejoin, v_ccr, v_ccr_reply, 'ccr.rejoin', 'Rejoinder in CCR', + 'Rejoinder in the counterclaim for revocation', + 'claimant', 'filing', true, 2, 'months', NULL, NULL, + false, NULL, 0, true); + + -- Interim Conference + v_ccr_interim := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_ccr_interim, v_ccr, v_ccr_rejoin, 'ccr.interim', 'Interim Conference', + 'Interim conference covering revocation issues', + 'court', 'hearing', true, 0, 'months', NULL, + 'May be combined with infringement IC', + false, NULL, 0, true); + + -- Oral Hearing + v_ccr_oral := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_ccr_oral, v_ccr, v_ccr_interim, 'ccr.oral', 'Oral Hearing', + 'Oral hearing on validity', + 'court', 'hearing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + -- Decision + v_ccr_decision := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_ccr_decision, v_ccr, v_ccr_oral, 'ccr.decision', 'Decision', + 'Decision on validity of the patent', + 'court', 'decision', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + -- Appeal from CCR + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (gen_random_uuid(), v_app, v_ccr_decision, 'ccr.appeal', 'Appeal', + 'Appeal against revocation decision to Court of Appeal', + 'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL, + true, 'Appeal filed', 0, true); + + -- Application to Amend Patent (spawn from Defence to CCR) + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (gen_random_uuid(), v_amd, v_ccr_defence, 'ccr.amend', 'Application to Amend Patent', + 'Patent proprietor applies to amend the patent during revocation proceedings', + 'claimant', 'filing', false, 0, 'months', NULL, NULL, + true, 'Includes application to amend patent', 2, true); + + -- ======================================== + -- STANDALONE REVOCATION + -- ======================================== + + v_rev_app := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_rev_app, v_rev, NULL, 'rev.app', 'Application for Revocation', + 'Applicant files standalone application for revocation of the patent', + 'claimant', 'filing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + v_rev_defence := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_rev_defence, v_rev, v_rev_app, 'rev.defence', 'Defence to Revocation', + 'Patent proprietor files defence to revocation application', + 'defendant', 'filing', true, 3, 'months', NULL, NULL, + false, NULL, 0, true); + + v_rev_reply := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_rev_reply, v_rev, v_rev_defence, 'rev.reply', 'Reply', + 'Reply in standalone revocation proceedings', + 'claimant', 'filing', true, 2, 'months', NULL, NULL, + false, NULL, 1, true); + + v_rev_rejoin := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_rev_rejoin, v_rev, v_rev_reply, 'rev.rejoin', 'Rejoinder', + 'Rejoinder in standalone revocation proceedings', + 'defendant', 'filing', true, 2, 'months', NULL, NULL, + false, NULL, 0, true); + + v_rev_interim := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_rev_interim, v_rev, v_rev_rejoin, 'rev.interim', 'Interim Conference', + 'Interim conference with the judge-rapporteur', + 'court', 'hearing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + v_rev_oral := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_rev_oral, v_rev, v_rev_interim, 'rev.oral', 'Oral Hearing', + 'Oral hearing on validity in standalone revocation', + 'court', 'hearing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + v_rev_decision := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_rev_decision, v_rev, v_rev_oral, 'rev.decision', 'Decision', + 'Decision on patent validity', + 'court', 'decision', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + -- Appeal from REV + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (gen_random_uuid(), v_app, v_rev_decision, 'rev.appeal', 'Appeal', + 'Appeal against revocation decision to Court of Appeal', + 'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL, + true, 'Appeal filed', 0, true); + + -- Application to Amend Patent from REV Defence + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (gen_random_uuid(), v_amd, v_rev_defence, 'rev.amend', 'Application to Amend Patent', + 'Patent proprietor applies to amend the patent', + 'claimant', 'filing', false, 0, 'months', NULL, NULL, + true, 'Includes application to amend patent', 2, true); + + -- ======================================== + -- PRELIMINARY INJUNCTION + -- ======================================== + + v_pi_app := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_pi_app, v_apm, NULL, 'pi.app', 'Application for Provisional Measures', + 'Claimant files application for preliminary injunction', + 'claimant', 'filing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + v_pi_resp := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_pi_resp, v_apm, v_pi_app, 'pi.response', 'Response to PI Application', + 'Defendant files response to preliminary injunction application', + 'defendant', 'filing', true, 0, 'months', NULL, + 'Deadline set by court', + false, NULL, 0, true); + + v_pi_oral := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_pi_oral, v_apm, v_pi_resp, 'pi.oral', 'Oral Hearing', + 'Oral hearing on provisional measures', + 'court', 'hearing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (gen_random_uuid(), v_apm, v_pi_oral, 'pi.order', 'Order on Provisional Measures', + 'Court issues order on preliminary injunction', + 'court', 'decision', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + -- ======================================== + -- APPEAL (standalone) + -- ======================================== + + v_app_notice := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_app_notice, v_app, NULL, 'app.notice', 'Notice of Appeal', + 'Appellant files notice of appeal with the Court of Appeal', + 'both', 'filing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + v_app_grounds := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_app_grounds, v_app, v_app_notice, 'app.grounds', 'Statement of Grounds of Appeal', + 'Appellant files statement of grounds', + 'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL, + false, NULL, 0, true); + + v_app_response := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_app_response, v_app, v_app_grounds, 'app.response', 'Response to Appeal', + 'Respondent files response to the appeal', + 'both', 'filing', true, 2, 'months', NULL, NULL, + false, NULL, 0, true); + + v_app_oral := gen_random_uuid(); + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (v_app_oral, v_app, v_app_response, 'app.oral', 'Oral Hearing', + 'Oral hearing before the Court of Appeal', + 'court', 'hearing', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description, + primary_party, event_type, is_mandatory, duration_value, duration_unit, + rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active) + VALUES (gen_random_uuid(), v_app, v_app_oral, 'app.decision', 'Decision', + 'Court of Appeal delivers its decision', + 'court', 'decision', true, 0, 'months', NULL, NULL, + false, NULL, 0, true); + + -- ======================================== + -- 5. Set conditional deadlines (from 040) + -- ======================================== + + -- Reply to Defence: rule code changes when CCR is active + -- Default: RoP.029b | With CCR: RoP.029a + UPDATE deadline_rules + SET condition_rule_id = v_ccr_root, + alt_rule_code = 'RoP.029a' + WHERE id = v_inf_reply; + + -- Rejoinder: duration changes when CCR is active + -- Default: 1 month RoP.029c | With CCR: 2 months RoP.029d + UPDATE deadline_rules + SET condition_rule_id = v_ccr_root, + alt_duration_value = 2, + alt_duration_unit = 'months', + alt_rule_code = 'RoP.029d' + WHERE id = v_inf_rejoin; + +END $$; diff --git a/frontend/src/app/(app)/fristen/rechner/page.tsx b/frontend/src/app/(app)/fristen/rechner/page.tsx index f6f254b..f4c3f75 100644 --- a/frontend/src/app/(app)/fristen/rechner/page.tsx +++ b/frontend/src/app/(app)/fristen/rechner/page.tsx @@ -1,28 +1,61 @@ "use client"; import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator"; +import { DeadlineWizard } from "@/components/deadlines/DeadlineWizard"; import { ArrowLeft } from "lucide-react"; import Link from "next/link"; +import { useState } from "react"; export default function FristenrechnerPage() { + const [mode, setMode] = useState<"wizard" | "quick">("wizard"); + return (
-
- - - Zurück zu Fristen - -

- Fristenrechner -

-

- Berechnen Sie Fristen basierend auf Verfahrensart und Auslösedatum -

+
+
+ + + Zurueck zu Fristen + +

+ Fristenbestimmung +

+

+ {mode === "wizard" + ? "Vollstaendige Verfahrens-Timeline mit automatischer Fristenberechnung" + : "Schnellberechnung einzelner Fristen nach Verfahrensart"} +

+
+ + {/* Mode toggle */} +
+ + +
- + + {mode === "wizard" ? : }
); } diff --git a/frontend/src/components/deadlines/DeadlineWizard.tsx b/frontend/src/components/deadlines/DeadlineWizard.tsx new file mode 100644 index 0000000..751a29d --- /dev/null +++ b/frontend/src/components/deadlines/DeadlineWizard.tsx @@ -0,0 +1,622 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { + ProceedingType, + TimelineResponse, + DetermineResponse, + TimelineEvent, + Case, +} from "@/lib/types"; +import { format, parseISO, isPast, isThisWeek, isBefore, addDays } from "date-fns"; +import { de } from "date-fns/locale"; +import { + Scale, + Calendar, + ChevronRight, + ChevronDown, + GitBranch, + Check, + Clock, + AlertTriangle, + FileText, + Users, + Gavel, + ArrowRight, + RotateCcw, + Loader2, + CheckCircle2, + FolderOpen, +} from "lucide-react"; +import { useState, useCallback, useMemo } from "react"; +import { toast } from "sonner"; + +// --- Helpers --- + +function formatDuration(value: number, unit: string): string { + if (value === 0) return ""; + const labels: Record = { + days: value === 1 ? "Tag" : "Tage", + weeks: value === 1 ? "Woche" : "Wochen", + months: value === 1 ? "Monat" : "Monate", + }; + return `${value} ${labels[unit] || unit}`; +} + +function getPartyIcon(party?: string) { + switch (party) { + case "claimant": + return ; + case "defendant": + return ; + case "court": + return ; + default: + return ; + } +} + +function getPartyLabel(party?: string): string { + switch (party) { + case "claimant": + return "Klaeger"; + case "defendant": + return "Beklagter"; + case "court": + return "Gericht"; + case "both": + return "Beide Parteien"; + default: + return ""; + } +} + +function getEventTypeLabel(type?: string): string { + switch (type) { + case "filing": + return "Einreichung"; + case "hearing": + return "Verhandlung"; + case "decision": + return "Entscheidung"; + default: + return ""; + } +} + +type Urgency = "past" | "overdue" | "this_week" | "upcoming" | "future" | "none"; + +function getUrgency(dateStr?: string): Urgency { + if (!dateStr) return "none"; + const date = parseISO(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (isPast(date) && isBefore(date, today)) return "overdue"; + if (isThisWeek(date, { weekStartsOn: 1 })) return "this_week"; + if (isBefore(date, addDays(today, 30))) return "upcoming"; + return "future"; +} + +const urgencyStyles: Record = { + past: { dot: "bg-neutral-400", text: "text-neutral-500", bg: "bg-neutral-50" }, + overdue: { dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50" }, + this_week: { dot: "bg-amber-500", text: "text-amber-700", bg: "bg-amber-50" }, + upcoming: { dot: "bg-blue-500", text: "text-blue-700", bg: "bg-blue-50" }, + future: { dot: "bg-green-500", text: "text-green-700", bg: "bg-green-50" }, + none: { dot: "bg-neutral-300", text: "text-neutral-500", bg: "bg-neutral-50" }, +}; + +// --- Spawn Extraction --- + +function extractSpawns(events: TimelineEvent[]): TimelineEvent[] { + const spawns: TimelineEvent[] = []; + function walk(evts: TimelineEvent[]) { + for (const ev of evts) { + if (ev.is_spawn) spawns.push(ev); + if (ev.children) walk(ev.children); + } + } + walk(events); + return spawns; +} + +// --- Flat timeline extraction --- + +function flattenTimeline(events: TimelineEvent[], depth = 0): (TimelineEvent & { depth: number })[] { + const result: (TimelineEvent & { depth: number })[] = []; + for (const ev of events) { + result.push({ ...ev, depth }); + if (ev.children && ev.children.length > 0) { + result.push(...flattenTimeline(ev.children, depth + 1)); + } + } + return result; +} + +// --- Main Component --- + +export function DeadlineWizard() { + const [selectedType, setSelectedType] = useState(""); + const [triggerDate, setTriggerDate] = useState(""); + const [conditions, setConditions] = useState>({}); + const [selectedCaseId, setSelectedCaseId] = useState(""); + const [showBatchPanel, setShowBatchPanel] = useState(false); + const queryClient = useQueryClient(); + + // Fetch proceeding types + const { data: proceedingTypes, isLoading: typesLoading } = useQuery({ + queryKey: ["proceeding-types"], + queryFn: () => api.get("/proceeding-types"), + }); + + // Fetch timeline structure when type is selected + const { data: timelineData } = useQuery({ + queryKey: ["timeline", selectedType], + queryFn: () => api.get(`/proceeding-types/${selectedType}/timeline`), + enabled: !!selectedType, + }); + + // Determine mutation + const determineMutation = useMutation({ + mutationFn: (params: { proceeding_type: string; trigger_event_date: string; conditions: Record }) => + api.post("/deadlines/determine", params), + }); + + // Cases for batch create + const { data: cases } = useQuery({ + queryKey: ["cases"], + queryFn: () => api.get("/cases"), + enabled: showBatchPanel, + }); + + // Batch create mutation + const batchMutation = useMutation({ + mutationFn: (params: { caseId: string; deadlines: { title: string; due_date: string; rule_code?: string }[] }) => + api.post(`/cases/${params.caseId}/deadlines/batch`, { deadlines: params.deadlines }), + onSuccess: () => { + toast.success("Alle Fristen wurden auf die Akte uebernommen"); + queryClient.invalidateQueries({ queryKey: ["deadlines"] }); + setShowBatchPanel(false); + }, + onError: () => { + toast.error("Fehler beim Erstellen der Fristen"); + }, + }); + + // Spawns from timeline structure (for condition toggles) + const spawns = useMemo(() => { + if (!timelineData?.timeline) return []; + return extractSpawns(timelineData.timeline); + }, [timelineData]); + + // Calculate on type/date/condition change + const calculate = useCallback(() => { + if (!selectedType || !triggerDate) return; + determineMutation.mutate({ + proceeding_type: selectedType, + trigger_event_date: triggerDate, + conditions, + }); + }, [selectedType, triggerDate, conditions, determineMutation]); + + // Auto-calculate when date or conditions change + const handleDateChange = (date: string) => { + setTriggerDate(date); + if (selectedType && date) { + determineMutation.mutate({ + proceeding_type: selectedType, + trigger_event_date: date, + conditions, + }); + } + }; + + const handleConditionToggle = (spawnId: string) => { + const next = { ...conditions, [spawnId]: !conditions[spawnId] }; + setConditions(next); + if (selectedType && triggerDate) { + determineMutation.mutate({ + proceeding_type: selectedType, + trigger_event_date: triggerDate, + conditions: next, + }); + } + }; + + const handleTypeSelect = (code: string) => { + setSelectedType(code); + setConditions({}); + if (triggerDate) { + // Will recalculate once timeline loads + determineMutation.reset(); + } + }; + + const handleReset = () => { + setSelectedType(""); + setTriggerDate(""); + setConditions({}); + setShowBatchPanel(false); + determineMutation.reset(); + }; + + // Collect calculated deadlines for batch create + const collectDeadlines = (events: TimelineEvent[]): { title: string; due_date: string; rule_code?: string }[] => { + const result: { title: string; due_date: string; rule_code?: string }[] = []; + for (const ev of events) { + if (ev.date && ev.duration_value > 0) { + result.push({ title: ev.name, due_date: ev.date, rule_code: ev.rule_code || undefined }); + } + if (ev.children) result.push(...collectDeadlines(ev.children)); + } + return result; + }; + + const results = determineMutation.data; + const selectedPT = proceedingTypes?.find((pt) => pt.code === selectedType); + + return ( +
+ {/* Step 1: Proceeding Type Selection */} +
+
+
+ + Verfahrensart waehlen +
+ {selectedType && ( + + )} +
+ +
+ {typesLoading ? ( +
+ +
+ ) : ( + proceedingTypes?.map((pt) => ( + + )) + )} +
+
+ + {/* Step 2: Date + Conditions */} + {selectedType && ( +
+
+ + Ausloesendes Ereignis +
+ +
+
+ + handleDateChange(e.target.value)} + className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> +
+ + {/* Condition toggles */} + {spawns.length > 0 && ( +
+ {spawns.map((spawn) => ( + + ))} +
+ )} +
+
+ )} + + {/* Error */} + {determineMutation.isError && ( +
+ + Fehler bei der Berechnung. Bitte Eingaben pruefen. +
+ )} + + {/* Step 3: Calculated Timeline */} + {results && results.timeline && ( +
+ {/* Header */} +
+
+

+ Verfahrens-Timeline: {results.proceeding_name} +

+

+ {results.total_deadlines} Ereignisse ab{" "} + {format(parseISO(results.trigger_event_date), "dd. MMMM yyyy", { locale: de })} +

+
+ +
+ + {/* Timeline visualization */} +
+ +
+ + {/* Batch create panel */} + {showBatchPanel && ( +
+
+ + Fristen auf Akte uebernehmen +
+
+ + +
+
+ )} +
+ )} + + {/* Empty state */} + {!results && !determineMutation.isPending && selectedType && triggerDate && ( +
+ +
+ )} + + {!selectedType && ( +
+
+ +
+

+ UPC-Fristenbestimmung +

+

+ Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein. + Alle Fristen des Verfahrens werden automatisch berechnet. +

+
+ )} +
+ ); +} + +// --- Timeline Tree Component --- + +function TimelineTree({ + events, + conditions, + depth, +}: { + events: TimelineEvent[]; + conditions: Record; + depth: number; +}) { + return ( + <> + {events.map((ev, i) => ( + + ))} + + ); +} + +function TimelineNode({ + event: ev, + conditions, + depth, + isLast, +}: { + event: TimelineEvent; + conditions: Record; + depth: number; + isLast: boolean; +}) { + const [expanded, setExpanded] = useState(true); + + // Skip inactive spawns + if (ev.is_spawn && !conditions[ev.id]) return null; + + const hasChildren = ev.children && ev.children.length > 0; + const visibleChildren = ev.children?.filter( + (c) => !c.is_spawn || conditions[c.id] + ); + const hasVisibleChildren = visibleChildren && visibleChildren.length > 0; + + const urgency = getUrgency(ev.date); + const styles = urgencyStyles[urgency]; + const duration = formatDuration(ev.duration_value, ev.duration_unit); + const isConditional = ev.has_condition && ev.condition_rule_id; + + return ( + <> +
+ {/* Timeline connector */} +
+
+ {!isLast &&
} +
+ + {/* Content */} +
+
+
+ {hasVisibleChildren && ( + + )} + {ev.is_spawn && ( + + )} + {ev.name} + {!ev.is_mandatory && ( + + optional + + )} +
+ + {/* Date */} + {ev.date && ( +
+ {ev.was_adjusted && ( + + angepasst + + )} + + {format(parseISO(ev.date), "dd.MM.yyyy")} + +
+ )} +
+ + {/* Meta row */} +
+ {ev.primary_party && ( + + {getPartyIcon(ev.primary_party)} + {getPartyLabel(ev.primary_party)} + + )} + {ev.event_type && ( + <> + · + {getEventTypeLabel(ev.event_type)} + + )} + {duration && ( + <> + · + + + {duration} + + + )} + {ev.rule_code && ( + <> + · + + {ev.rule_code} + + + )} + {isConditional && ( + <> + · + + bedingt{ev.alt_rule_code ? ` (${ev.alt_rule_code})` : ""} + + + )} +
+ + {/* Notes */} + {ev.deadline_notes && ( +

{ev.deadline_notes}

+ )} +
+
+ + {/* Children */} + {expanded && hasVisibleChildren && ( + + )} + + ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a842be4..fcfdfed 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -120,12 +120,76 @@ export interface DeadlineRule { rule_code?: string; deadline_notes?: string; sequence_order: number; + condition_rule_id?: string; + alt_duration_value?: number; + alt_duration_unit?: string; + alt_rule_code?: string; + is_spawn?: boolean; + spawn_label?: string; } export interface RuleTreeNode extends DeadlineRule { children?: RuleTreeNode[]; } +// Timeline determination types + +export interface TimelineEvent { + id: string; + code?: string; + name: string; + description?: string; + primary_party?: string; + event_type?: string; + is_mandatory: boolean; + duration_value: number; + duration_unit: string; + rule_code?: string; + deadline_notes?: string; + is_spawn: boolean; + spawn_label?: string; + has_condition: boolean; + condition_rule_id?: string; + alt_rule_code?: string; + alt_duration_value?: number; + alt_duration_unit?: string; + date?: string; + original_date?: string; + was_adjusted: boolean; + children?: TimelineEvent[]; +} + +export interface TimelineResponse { + proceeding_type: ProceedingType; + timeline: TimelineEvent[]; +} + +export interface DetermineRequest { + proceeding_type: string; + trigger_event_date: string; + conditions: Record; +} + +export interface DetermineResponse { + proceeding_type: string; + proceeding_name: string; + proceeding_color: string; + trigger_event_date: string; + timeline: TimelineEvent[]; + total_deadlines: number; +} + +export interface BatchCreateRequest { + deadlines: { + title: string; + due_date: string; + original_due_date?: string; + rule_id?: string; + rule_code?: string; + notes?: string; + }[]; +} + export interface ProceedingType { id: number; code: string;