diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index 9553104..35bfa8c 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -11,9 +11,9 @@ type contextKey string const ( userIDKey contextKey = "user_id" tenantIDKey contextKey = "tenant_id" - userRoleKey contextKey = "user_role" ipKey contextKey = "ip_address" userAgentKey contextKey = "user_agent" + userRoleKey contextKey = "user_role" ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -34,15 +34,6 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { return id, ok } -func ContextWithUserRole(ctx context.Context, role string) context.Context { - return context.WithValue(ctx, userRoleKey, role) -} - -func UserRoleFromContext(ctx context.Context) string { - role, _ := ctx.Value(userRoleKey).(string) - return role -} - func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { ctx = context.WithValue(ctx, ipKey, ip) ctx = context.WithValue(ctx, userAgentKey, userAgent) @@ -62,3 +53,12 @@ func UserAgentFromContext(ctx context.Context) *string { } return nil } + +func ContextWithUserRole(ctx context.Context, role string) context.Context { + return context.WithValue(ctx, userRoleKey, role) +} + +func UserRoleFromContext(ctx context.Context) string { + role, _ := ctx.Value(userRoleKey).(string) + return role +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 1a51b11..aeb158c 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -43,8 +43,7 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent()) - // Tenant/role resolution is handled by TenantResolver middleware for scoped routes. - // Tenant management routes handle their own access control. + // 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 811f872..49d11ba 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -34,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) @@ -41,6 +42,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) return } + // Verify user has access and get their role role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) if err != nil { @@ -54,7 +56,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { } tenantID = parsed - r = r.WithContext(ContextWithUserRole(r.Context(), role)) + ctx = ContextWithUserRole(ctx, role) } else { // Default to user's first tenant first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) @@ -69,17 +71,17 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { } tenantID = *first - // Resolve role for default tenant + // Look up role for default tenant role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID) if err != nil { - slog.Error("failed to resolve role for default tenant", "error", err) + slog.Error("failed to get user role", "error", err, "user_id", userID, "tenant_id", tenantID) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } - r = r.WithContext(ContextWithUserRole(r.Context(), role)) + 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 c35d1f2..e03fed7 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -12,7 +12,6 @@ import ( type mockTenantLookup struct { tenantID *uuid.UUID role string - roleSet bool // true means role was explicitly set (even if empty) err error } @@ -21,10 +20,7 @@ func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.U } func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { - if m.roleSet { - return m.role, m.err - } - return "associate", m.err + return m.role, m.err } func TestTenantResolver_FromHeader(t *testing.T) { @@ -32,12 +28,14 @@ func TestTenantResolver_FromHeader(t *testing.T) { tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) 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) }) @@ -54,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{role: "", roleSet: true}) + tr := NewTenantResolver(&mockTenantLookup{role: ""}) next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next should not be called") @@ -78,7 +79,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) { tenantID := uuid.New() - tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "owner"}) + 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/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 f1c3226..f2a26ce 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -28,6 +28,7 @@ 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) documentSvc := services.NewDocumentService(db, storageCli, auditSvc) assignmentSvc := services.NewCaseAssignmentService(db) @@ -64,6 +65,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) @@ -111,7 +113,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 @@ -137,6 +139,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) @@ -169,7 +176,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se 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 { 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 c9f3fcd..c7d44af 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;