diff --git a/backend/internal/handlers/calculate.go b/backend/internal/handlers/calculate.go new file mode 100644 index 0000000..7637fdd --- /dev/null +++ b/backend/internal/handlers/calculate.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +// CalculateHandlers holds handlers for deadline calculation endpoints +type CalculateHandlers struct { + calculator *services.DeadlineCalculator + rules *services.DeadlineRuleService +} + +// NewCalculateHandlers creates calculate handlers +func NewCalculateHandlers(calc *services.DeadlineCalculator, rules *services.DeadlineRuleService) *CalculateHandlers { + return &CalculateHandlers{calculator: calc, rules: rules} +} + +// CalculateRequest is the input for POST /api/deadlines/calculate +type CalculateRequest struct { + ProceedingType string `json:"proceeding_type"` + TriggerEventDate string `json:"trigger_event_date"` + SelectedRuleIDs []string `json:"selected_rule_ids,omitempty"` +} + +// Calculate handles POST /api/deadlines/calculate +func (h *CalculateHandlers) Calculate(w http.ResponseWriter, r *http.Request) { + var req CalculateRequest + 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 + } + + eventDate, err := time.Parse("2006-01-02", req.TriggerEventDate) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid trigger_event_date format, expected YYYY-MM-DD") + return + } + + var results []services.CalculatedDeadline + + if len(req.SelectedRuleIDs) > 0 { + ruleModels, err := h.rules.GetByIDs(req.SelectedRuleIDs) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch selected rules") + return + } + results = h.calculator.CalculateFromRules(eventDate, ruleModels) + } else { + tree, err := h.rules.GetRuleTree(req.ProceedingType) + if err != nil { + writeError(w, http.StatusBadRequest, "unknown proceeding type") + return + } + // Flatten tree to get all rule models + var flatNodes []services.RuleTreeNode + flattenTree(tree, &flatNodes) + + ruleModels := make([]models.DeadlineRule, 0, len(flatNodes)) + for _, node := range flatNodes { + ruleModels = append(ruleModels, node.DeadlineRule) + } + results = h.calculator.CalculateFromRules(eventDate, ruleModels) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "proceeding_type": req.ProceedingType, + "trigger_event_date": req.TriggerEventDate, + "deadlines": results, + }) +} + +func flattenTree(nodes []services.RuleTreeNode, result *[]services.RuleTreeNode) { + for _, n := range nodes { + *result = append(*result, n) + if len(n.Children) > 0 { + flattenTree(n.Children, result) + } + } +} diff --git a/backend/internal/handlers/deadline_rules.go b/backend/internal/handlers/deadline_rules.go new file mode 100644 index 0000000..6c410b1 --- /dev/null +++ b/backend/internal/handlers/deadline_rules.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "net/http" + "strconv" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +// DeadlineRuleHandlers holds handlers for deadline rule endpoints +type DeadlineRuleHandlers struct { + rules *services.DeadlineRuleService +} + +// NewDeadlineRuleHandlers creates deadline rule handlers +func NewDeadlineRuleHandlers(rs *services.DeadlineRuleService) *DeadlineRuleHandlers { + return &DeadlineRuleHandlers{rules: rs} +} + +// List handles GET /api/deadline-rules +// Query params: proceeding_type_id (optional int filter) +func (h *DeadlineRuleHandlers) List(w http.ResponseWriter, r *http.Request) { + var proceedingTypeID *int + if v := r.URL.Query().Get("proceeding_type_id"); v != "" { + id, err := strconv.Atoi(v) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid proceeding_type_id") + return + } + proceedingTypeID = &id + } + + rules, err := h.rules.List(proceedingTypeID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list deadline rules") + return + } + + writeJSON(w, http.StatusOK, rules) +} + +// GetRuleTree handles GET /api/deadline-rules/{type} +// {type} is the proceeding type code (e.g., "INF", "REV") +func (h *DeadlineRuleHandlers) GetRuleTree(w http.ResponseWriter, r *http.Request) { + typeCode := r.PathValue("type") + if typeCode == "" { + writeError(w, http.StatusBadRequest, "proceeding type code required") + return + } + + tree, err := h.rules.GetRuleTree(typeCode) + if err != nil { + writeError(w, http.StatusNotFound, "proceeding type not found") + return + } + + writeJSON(w, http.StatusOK, tree) +} diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go new file mode 100644 index 0000000..844d243 --- /dev/null +++ b/backend/internal/handlers/deadlines.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +// DeadlineHandlers holds handlers for deadline CRUD endpoints +type DeadlineHandlers struct { + deadlines *services.DeadlineService + db *sqlx.DB +} + +// NewDeadlineHandlers creates deadline handlers +func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHandlers { + return &DeadlineHandlers{deadlines: ds, db: db} +} + +// ListForCase handles GET /api/cases/{caseID}/deadlines +func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) { + tenantID, err := resolveTenant(r, h.db) + if err != nil { + handleTenantError(w, err) + return + } + + caseID, err := parsePathUUID(r, "caseID") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + deadlines, err := h.deadlines.ListForCase(tenantID, caseID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list deadlines") + return + } + + writeJSON(w, http.StatusOK, deadlines) +} + +// Create handles POST /api/cases/{caseID}/deadlines +func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) { + tenantID, err := resolveTenant(r, h.db) + if err != nil { + handleTenantError(w, err) + return + } + + caseID, err := parsePathUUID(r, "caseID") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + var input services.CreateDeadlineInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + input.CaseID = caseID + + if input.Title == "" || input.DueDate == "" { + writeError(w, http.StatusBadRequest, "title and due_date are required") + return + } + + deadline, err := h.deadlines.Create(tenantID, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create deadline") + return + } + + writeJSON(w, http.StatusCreated, deadline) +} + +// Update handles PUT /api/deadlines/{deadlineID} +func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) { + tenantID, err := resolveTenant(r, h.db) + if err != nil { + handleTenantError(w, err) + return + } + + deadlineID, err := parsePathUUID(r, "deadlineID") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid deadline ID") + return + } + + var input services.UpdateDeadlineInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + deadline, err := h.deadlines.Update(tenantID, deadlineID, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update deadline") + return + } + if deadline == nil { + writeError(w, http.StatusNotFound, "deadline not found") + return + } + + writeJSON(w, http.StatusOK, deadline) +} + +// Complete handles PATCH /api/deadlines/{deadlineID}/complete +func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) { + tenantID, err := resolveTenant(r, h.db) + if err != nil { + handleTenantError(w, err) + return + } + + deadlineID, err := parsePathUUID(r, "deadlineID") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid deadline ID") + return + } + + deadline, err := h.deadlines.Complete(tenantID, deadlineID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to complete deadline") + return + } + if deadline == nil { + writeError(w, http.StatusNotFound, "deadline not found") + return + } + + writeJSON(w, http.StatusOK, deadline) +} + +// Delete handles DELETE /api/deadlines/{deadlineID} +func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { + tenantID, err := resolveTenant(r, h.db) + if err != nil { + handleTenantError(w, err) + return + } + + deadlineID, err := parsePathUUID(r, "deadlineID") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid deadline ID") + return + } + + err = h.deadlines.Delete(tenantID, deadlineID) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/backend/internal/handlers/helpers.go b/backend/internal/handlers/helpers.go index ce23c1d..59a7e5a 100644 --- a/backend/internal/handlers/helpers.go +++ b/backend/internal/handlers/helpers.go @@ -3,14 +3,83 @@ package handlers import ( "encoding/json" "net/http" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" ) -func writeJSON(w http.ResponseWriter, status int, v interface{}) { +func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// resolveTenant gets the tenant ID for the authenticated user. +// Checks X-Tenant-ID header first, then falls back to user's first tenant. +func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) { + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + return uuid.Nil, errUnauthorized + } + + // Check header first + if headerVal := r.Header.Get("X-Tenant-ID"); headerVal != "" { + tenantID, err := uuid.Parse(headerVal) + if err != nil { + return uuid.Nil, errInvalidTenant + } + // Verify user has access to this tenant + var count int + err = db.Get(&count, + `SELECT COUNT(*) FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`, + userID, tenantID) + if err != nil || count == 0 { + return uuid.Nil, errTenantAccess + } + return tenantID, nil + } + + // Fall back to user's first tenant + var tenantID uuid.UUID + err := db.Get(&tenantID, + `SELECT tenant_id FROM user_tenants WHERE user_id = $1 ORDER BY created_at LIMIT 1`, + userID) + if err != nil { + return uuid.Nil, errNoTenant + } + return tenantID, nil +} + +type apiError struct { + msg string + status int +} + +func (e *apiError) Error() string { return e.msg } + +var ( + errUnauthorized = &apiError{msg: "unauthorized", status: http.StatusUnauthorized} + errInvalidTenant = &apiError{msg: "invalid tenant ID", status: http.StatusBadRequest} + errTenantAccess = &apiError{msg: "no access to tenant", status: http.StatusForbidden} + errNoTenant = &apiError{msg: "no tenant found for user", status: http.StatusBadRequest} +) + +// handleTenantError writes the appropriate error response for tenant resolution errors +func handleTenantError(w http.ResponseWriter, err error) { + if ae, ok := err.(*apiError); ok { + writeError(w, ae.status, ae.msg) + return + } + writeError(w, http.StatusInternalServerError, "internal error") +} + +// parsePathUUID extracts a UUID from the URL path using PathValue +func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) { + return uuid.Parse(r.PathValue(key)) } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 142aab3..4b21e0d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -19,6 +19,10 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { caseSvc := services.NewCaseService(db) partySvc := services.NewPartyService(db) appointmentSvc := services.NewAppointmentService(db) + holidaySvc := services.NewHolidayService(db) + deadlineSvc := services.NewDeadlineService(db) + deadlineRuleSvc := services.NewDeadlineRuleService(db) + calculator := services.NewDeadlineCalculator(holidaySvc) // Middleware tenantResolver := auth.NewTenantResolver(tenantSvc) @@ -28,6 +32,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { caseH := handlers.NewCaseHandler(caseSvc) partyH := handlers.NewPartyHandler(partySvc) apptH := handlers.NewAppointmentHandler(appointmentSvc) + deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db) + ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) + calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -59,6 +66,20 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update) scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete) + // Deadlines + scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase) + scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create) + scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update) + scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete) + scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete) + + // Deadline rules (reference data) + scoped.HandleFunc("GET /api/deadline-rules", ruleH.List) + scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree) + + // Deadline calculator + scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) + // Appointments scoped.HandleFunc("GET /api/appointments", apptH.List) scoped.HandleFunc("POST /api/appointments", apptH.Create) @@ -66,7 +87,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete) // Placeholder routes for future phases - scoped.HandleFunc("GET /api/deadlines", placeholder("deadlines")) scoped.HandleFunc("GET /api/documents", placeholder("documents")) // Wire: auth -> tenant routes go directly, scoped routes get tenant resolver diff --git a/backend/internal/services/deadline_calculator.go b/backend/internal/services/deadline_calculator.go new file mode 100644 index 0000000..80c472e --- /dev/null +++ b/backend/internal/services/deadline_calculator.go @@ -0,0 +1,99 @@ +package services + +import ( + "time" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +// CalculatedDeadline holds a calculated deadline with adjustment info +type CalculatedDeadline struct { + RuleCode string `json:"rule_code"` + RuleID string `json:"rule_id"` + Title string `json:"title"` + DueDate string `json:"due_date"` + OriginalDueDate string `json:"original_due_date"` + WasAdjusted bool `json:"was_adjusted"` +} + +// DeadlineCalculator calculates deadlines from rules and event dates +type DeadlineCalculator struct { + holidays *HolidayService +} + +// NewDeadlineCalculator creates a new calculator +func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator { + return &DeadlineCalculator{holidays: holidays} +} + +// CalculateEndDate calculates the end date for a single deadline rule based on an event date. +// Adapted from youpc.org CalculateDeadlineEndDate. +func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule) (adjusted time.Time, original time.Time, wasAdjusted bool) { + endDate := eventDate + + timing := "after" + if rule.Timing != nil { + timing = *rule.Timing + } + + durationValue := rule.DurationValue + durationUnit := rule.DurationUnit + + if timing == "before" { + switch durationUnit { + case "days": + endDate = endDate.AddDate(0, 0, -durationValue) + case "weeks": + endDate = endDate.AddDate(0, 0, -durationValue*7) + case "months": + endDate = endDate.AddDate(0, -durationValue, 0) + } + } else { + switch durationUnit { + case "days": + endDate = endDate.AddDate(0, 0, durationValue) + case "weeks": + endDate = endDate.AddDate(0, 0, durationValue*7) + case "months": + endDate = endDate.AddDate(0, durationValue, 0) + } + } + + original = endDate + adjusted, _, wasAdjusted = c.holidays.AdjustForNonWorkingDays(endDate) + return adjusted, original, wasAdjusted +} + +// CalculateFromRules calculates deadlines for a set of rules given an event date. +// Returns a list of calculated deadlines with due dates. +func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []models.DeadlineRule) []CalculatedDeadline { + results := make([]CalculatedDeadline, 0, len(rules)) + + for _, rule := range rules { + var adjusted, original time.Time + var wasAdjusted bool + + if rule.DurationValue > 0 { + adjusted, original, wasAdjusted = c.CalculateEndDate(eventDate, rule) + } else { + adjusted = eventDate + original = eventDate + } + + code := "" + if rule.Code != nil { + code = *rule.Code + } + + results = append(results, CalculatedDeadline{ + RuleCode: code, + RuleID: rule.ID.String(), + Title: rule.Name, + DueDate: adjusted.Format("2006-01-02"), + OriginalDueDate: original.Format("2006-01-02"), + WasAdjusted: wasAdjusted, + }) + } + + return results +} diff --git a/backend/internal/services/deadline_calculator_test.go b/backend/internal/services/deadline_calculator_test.go new file mode 100644 index 0000000..0ab70ab --- /dev/null +++ b/backend/internal/services/deadline_calculator_test.go @@ -0,0 +1,141 @@ +package services + +import ( + "testing" + "time" + + "github.com/google/uuid" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +func TestCalculateEndDateAfterDays(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday + timing := "after" + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "Test 10 days", + DurationValue: 10, + DurationUnit: "days", + Timing: &timing, + } + + adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule) + + // 25 March + 10 days = 4 April 2026 (Saturday) + // Apr 5 = Easter Sunday (holiday), Apr 6 = Easter Monday (holiday) -> adjusted to 7 April (Tuesday) + expectedOriginal := time.Date(2026, 4, 4, 0, 0, 0, 0, time.UTC) + expectedAdjusted := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC) + + if original != expectedOriginal { + t.Errorf("original should be %s, got %s", expectedOriginal, original) + } + if adjusted != expectedAdjusted { + t.Errorf("adjusted should be %s, got %s", expectedAdjusted, adjusted) + } + if !wasAdjusted { + t.Error("should have been adjusted (Saturday)") + } +} + +func TestCalculateEndDateBeforeMonths(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + eventDate := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) // Monday + timing := "before" + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "Test 2 months before", + DurationValue: 2, + DurationUnit: "months", + Timing: &timing, + } + + adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule) + + // 15 June - 2 months = 15 April 2026 (Wednesday) + expected := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC) + + if original != expected { + t.Errorf("original should be %s, got %s", expected, original) + } + if adjusted != expected { + t.Errorf("adjusted should be %s (not a holiday/weekend), got %s", expected, adjusted) + } + if wasAdjusted { + t.Error("should not have been adjusted (Wednesday)") + } +} + +func TestCalculateEndDateWeeks(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday + timing := "after" + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "Test 2 weeks", + DurationValue: 2, + DurationUnit: "weeks", + Timing: &timing, + } + + adjusted, original, _ := calc.CalculateEndDate(eventDate, rule) + + // 25 March + 14 days = 8 April 2026 (Wednesday) + expected := time.Date(2026, 4, 8, 0, 0, 0, 0, time.UTC) + if original != expected { + t.Errorf("original should be %s, got %s", expected, original) + } + if adjusted != expected { + t.Errorf("adjusted should be %s, got %s", expected, adjusted) + } +} + +func TestCalculateFromRules(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) + timing := "after" + code := "TEST-1" + + rules := []models.DeadlineRule{ + { + ID: uuid.New(), + Code: &code, + Name: "Rule A", + DurationValue: 7, + DurationUnit: "days", + Timing: &timing, + }, + { + ID: uuid.New(), + Name: "Rule B (zero duration)", + DurationValue: 0, + DurationUnit: "days", + }, + } + + results := calc.CalculateFromRules(eventDate, rules) + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + // Rule A: 25 March + 7 = 1 April (Wednesday) + if results[0].DueDate != "2026-04-01" { + t.Errorf("Rule A due date should be 2026-04-01, got %s", results[0].DueDate) + } + if results[0].RuleCode != "TEST-1" { + t.Errorf("Rule A code should be TEST-1, got %s", results[0].RuleCode) + } + + // Rule B: zero duration -> event date + if results[1].DueDate != "2026-03-25" { + t.Errorf("Rule B due date should be 2026-03-25, got %s", results[1].DueDate) + } +} diff --git a/backend/internal/services/deadline_rule_service.go b/backend/internal/services/deadline_rule_service.go new file mode 100644 index 0000000..ae6ee1d --- /dev/null +++ b/backend/internal/services/deadline_rule_service.go @@ -0,0 +1,175 @@ +package services + +import ( + "fmt" + + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +// DeadlineRuleService handles deadline rule queries +type DeadlineRuleService struct { + db *sqlx.DB +} + +// NewDeadlineRuleService creates a new deadline rule service +func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService { + return &DeadlineRuleService{db: db} +} + +// List returns deadline rules, optionally filtered by proceeding type +func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule, error) { + var rules []models.DeadlineRule + var err error + + 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 + 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 + FROM deadline_rules + WHERE is_active = true + ORDER BY proceeding_type_id, sequence_order`) + } + + if err != nil { + return nil, fmt.Errorf("listing deadline rules: %w", err) + } + return rules, nil +} + +// RuleTreeNode represents a deadline rule with its children +type RuleTreeNode struct { + models.DeadlineRule + Children []RuleTreeNode `json:"children,omitempty"` +} + +// GetRuleTree returns a hierarchical tree of rules for a proceeding type +func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTreeNode, error) { + // First resolve proceeding type code to ID + 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, fmt.Errorf("resolving proceeding type %q: %w", proceedingTypeCode, err) + } + + // 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 + FROM deadline_rules + WHERE proceeding_type_id = $1 AND is_active = true + ORDER BY sequence_order`, pt.ID) + if err != nil { + return nil, fmt.Errorf("listing rules for type %q: %w", proceedingTypeCode, err) + } + + return buildTree(rules), nil +} + +// GetByIDs returns deadline rules by their IDs +func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) { + if len(ids) == 0 { + return nil, nil + } + + 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 + FROM deadline_rules + WHERE id IN (?) AND is_active = true + ORDER BY sequence_order`, ids) + if err != nil { + return nil, fmt.Errorf("building IN query: %w", err) + } + query = s.db.Rebind(query) + + var rules []models.DeadlineRule + err = s.db.Select(&rules, query, args...) + if err != nil { + return nil, fmt.Errorf("fetching rules by IDs: %w", err) + } + return rules, nil +} + +// GetRulesForProceedingType returns all active rules for a proceeding type ID +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 + FROM deadline_rules + WHERE proceeding_type_id = $1 AND is_active = true + ORDER BY sequence_order`, proceedingTypeID) + if err != nil { + return nil, fmt.Errorf("listing rules for proceeding type %d: %w", proceedingTypeID, err) + } + return rules, nil +} + +// ListProceedingTypes returns all active proceeding types +func (s *DeadlineRuleService) ListProceedingTypes() ([]models.ProceedingType, error) { + var types []models.ProceedingType + err := s.db.Select(&types, + `SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active + FROM proceeding_types + WHERE is_active = true + ORDER BY sort_order`) + if err != nil { + return nil, fmt.Errorf("listing proceeding types: %w", err) + } + return types, nil +} + +// buildTree converts a flat list of rules into a hierarchical tree +func buildTree(rules []models.DeadlineRule) []RuleTreeNode { + nodeMap := make(map[string]*RuleTreeNode, len(rules)) + var roots []RuleTreeNode + + // Create nodes + for _, r := range rules { + node := RuleTreeNode{DeadlineRule: r} + nodeMap[r.ID.String()] = &node + } + + // Build tree + for _, r := range rules { + node := nodeMap[r.ID.String()] + if r.ParentID != nil { + parentKey := r.ParentID.String() + if parent, ok := nodeMap[parentKey]; ok { + parent.Children = append(parent.Children, *node) + continue + } + } + roots = append(roots, *node) + } + + return roots +} diff --git a/backend/internal/services/deadline_service.go b/backend/internal/services/deadline_service.go new file mode 100644 index 0000000..4923e13 --- /dev/null +++ b/backend/internal/services/deadline_service.go @@ -0,0 +1,180 @@ +package services + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +// DeadlineService handles CRUD operations for case deadlines +type DeadlineService struct { + db *sqlx.DB +} + +// NewDeadlineService creates a new deadline service +func NewDeadlineService(db *sqlx.DB) *DeadlineService { + return &DeadlineService{db: db} +} + +// ListForCase returns all deadlines for a case, scoped to tenant +func (s *DeadlineService) ListForCase(tenantID, caseID uuid.UUID) ([]models.Deadline, error) { + query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date, + warning_date, source, rule_id, status, completed_at, + caldav_uid, caldav_etag, notes, created_at, updated_at + FROM deadlines + WHERE tenant_id = $1 AND case_id = $2 + ORDER BY due_date ASC` + + var deadlines []models.Deadline + err := s.db.Select(&deadlines, query, tenantID, caseID) + if err != nil { + return nil, fmt.Errorf("listing deadlines for case: %w", err) + } + return deadlines, nil +} + +// GetByID returns a single deadline by ID, scoped to tenant +func (s *DeadlineService) GetByID(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) { + query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date, + warning_date, source, rule_id, status, completed_at, + caldav_uid, caldav_etag, notes, created_at, updated_at + FROM deadlines + WHERE tenant_id = $1 AND id = $2` + + var d models.Deadline + err := s.db.Get(&d, query, tenantID, deadlineID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("getting deadline: %w", err) + } + return &d, nil +} + +// CreateDeadlineInput holds the fields for creating a deadline +type CreateDeadlineInput struct { + CaseID uuid.UUID `json:"case_id"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + DueDate string `json:"due_date"` + WarningDate *string `json:"warning_date,omitempty"` + Source string `json:"source"` + RuleID *uuid.UUID `json:"rule_id,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +// Create inserts a new deadline +func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) { + id := uuid.New() + source := input.Source + if source == "" { + source = "manual" + } + + query := `INSERT INTO deadlines (id, tenant_id, case_id, title, description, due_date, + warning_date, source, rule_id, status, notes, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, NOW(), NOW()) + RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date, + warning_date, source, rule_id, status, completed_at, + caldav_uid, caldav_etag, notes, created_at, updated_at` + + var d models.Deadline + err := s.db.Get(&d, query, id, tenantID, input.CaseID, input.Title, input.Description, + input.DueDate, input.WarningDate, source, input.RuleID, input.Notes) + if err != nil { + return nil, fmt.Errorf("creating deadline: %w", err) + } + return &d, nil +} + +// UpdateDeadlineInput holds the fields for updating a deadline +type UpdateDeadlineInput struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + DueDate *string `json:"due_date,omitempty"` + WarningDate *string `json:"warning_date,omitempty"` + Notes *string `json:"notes,omitempty"` + Status *string `json:"status,omitempty"` + RuleID *uuid.UUID `json:"rule_id,omitempty"` +} + +// Update modifies an existing deadline +func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) { + // First check it exists and belongs to tenant + existing, err := s.GetByID(tenantID, deadlineID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, nil + } + + query := `UPDATE deadlines SET + title = COALESCE($1, title), + description = COALESCE($2, description), + due_date = COALESCE($3, due_date), + warning_date = COALESCE($4, warning_date), + notes = COALESCE($5, notes), + status = COALESCE($6, status), + rule_id = COALESCE($7, rule_id), + updated_at = NOW() + WHERE id = $8 AND tenant_id = $9 + RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date, + warning_date, source, rule_id, status, completed_at, + caldav_uid, caldav_etag, notes, created_at, updated_at` + + var d models.Deadline + err = s.db.Get(&d, query, input.Title, input.Description, input.DueDate, + input.WarningDate, input.Notes, input.Status, input.RuleID, + deadlineID, tenantID) + if err != nil { + return nil, fmt.Errorf("updating deadline: %w", err) + } + return &d, nil +} + +// Complete marks a deadline as completed +func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) { + query := `UPDATE deadlines SET + status = 'completed', + completed_at = $1, + updated_at = NOW() + WHERE id = $2 AND tenant_id = $3 + RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date, + warning_date, source, rule_id, status, completed_at, + caldav_uid, caldav_etag, notes, created_at, updated_at` + + var d models.Deadline + err := s.db.Get(&d, query, time.Now(), deadlineID, tenantID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("completing deadline: %w", err) + } + return &d, nil +} + +// Delete removes a deadline +func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error { + query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2` + result, err := s.db.Exec(query, deadlineID, tenantID) + if err != nil { + return fmt.Errorf("deleting deadline: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking delete result: %w", err) + } + if rows == 0 { + return fmt.Errorf("deadline not found") + } + return nil +} diff --git a/backend/internal/services/holidays.go b/backend/internal/services/holidays.go new file mode 100644 index 0000000..806cc51 --- /dev/null +++ b/backend/internal/services/holidays.go @@ -0,0 +1,193 @@ +package services + +import ( + "fmt" + "strings" + "time" + + "github.com/jmoiron/sqlx" +) + +// Holiday represents a non-working day +type Holiday struct { + Date time.Time + Name string + IsVacation bool // Part of court vacation period + IsClosure bool // Single-day closure (public holiday) +} + +// HolidayService manages holiday data and non-working day checks +type HolidayService struct { + db *sqlx.DB + // Cached holidays by year + cache map[int][]Holiday +} + +// NewHolidayService creates a holiday service +func NewHolidayService(db *sqlx.DB) *HolidayService { + return &HolidayService{ + db: db, + cache: make(map[int][]Holiday), + } +} + +// dbHoliday matches the holidays table schema +type dbHoliday struct { + ID int `db:"id"` + Date time.Time `db:"date"` + Name string `db:"name"` + Country string `db:"country"` + State *string `db:"state"` + HolidayType string `db:"holiday_type"` +} + +// LoadHolidaysForYear loads holidays from DB for a given year, merges with +// German federal holidays, and caches the result. +func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) { + if cached, ok := s.cache[year]; ok { + return cached, nil + } + + holidays := make([]Holiday, 0, 30) + + // Load from DB if available + if s.db != nil { + var dbHolidays []dbHoliday + err := s.db.Select(&dbHolidays, + `SELECT id, date, name, country, state, holiday_type + FROM holidays + WHERE EXTRACT(YEAR FROM date) = $1 + ORDER BY date`, year) + if err == nil { + for _, h := range dbHolidays { + holidays = append(holidays, Holiday{ + Date: h.Date, + Name: h.Name, + IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure", + IsVacation: h.HolidayType == "vacation", + }) + } + } + // If DB query fails, fall through to hardcoded holidays + } + + // Always add German federal holidays (if not already present from DB) + federal := germanFederalHolidays(year) + existing := make(map[string]bool, len(holidays)) + for _, h := range holidays { + existing[h.Date.Format("2006-01-02")] = true + } + for _, h := range federal { + key := h.Date.Format("2006-01-02") + if !existing[key] { + holidays = append(holidays, h) + } + } + + s.cache[year] = holidays + return holidays, nil +} + +// IsHoliday checks if a date is a holiday +func (s *HolidayService) IsHoliday(date time.Time) *Holiday { + year := date.Year() + holidays, err := s.LoadHolidaysForYear(year) + if err != nil { + return nil + } + + dateStr := date.Format("2006-01-02") + for i := range holidays { + if holidays[i].Date.Format("2006-01-02") == dateStr { + return &holidays[i] + } + } + return nil +} + +// IsNonWorkingDay returns true if the date is a weekend or holiday +func (s *HolidayService) IsNonWorkingDay(date time.Time) bool { + wd := date.Weekday() + if wd == time.Saturday || wd == time.Sunday { + return true + } + return s.IsHoliday(date) != nil +} + +// AdjustForNonWorkingDays moves the date to the next working day +// if it falls on a weekend or holiday. +// Returns adjusted date, original date, and whether adjustment was made. +func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) { + original = date + adjusted = date + + // Safety limit: max 30 days forward + for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ { + adjusted = adjusted.AddDate(0, 0, 1) + wasAdjusted = true + } + + return adjusted, original, wasAdjusted +} + +// ClearCache clears the holiday cache (useful after DB updates) +func (s *HolidayService) ClearCache() { + s.cache = make(map[int][]Holiday) +} + +// germanFederalHolidays returns all German federal public holidays for a year. +// These are holidays observed in all 16 German states. +func germanFederalHolidays(year int) []Holiday { + easterMonth, easterDay := CalculateEasterSunday(year) + easter := time.Date(year, time.Month(easterMonth), easterDay, 0, 0, 0, 0, time.UTC) + + holidays := []Holiday{ + {Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", IsClosure: true}, + {Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", IsClosure: true}, + {Date: easter, Name: "Ostersonntag", IsClosure: true}, + {Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", IsClosure: true}, + {Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", IsClosure: true}, + {Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", IsClosure: true}, + {Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", IsClosure: true}, + {Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", IsClosure: true}, + {Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", IsClosure: true}, + {Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", IsClosure: true}, + {Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", IsClosure: true}, + } + + return holidays +} + +// CalculateEasterSunday computes Easter Sunday using the Anonymous Gregorian algorithm. +// Returns month (1-12) and day. +func CalculateEasterSunday(year int) (int, int) { + a := year % 19 + b := year / 100 + c := year % 100 + d := b / 4 + e := b % 4 + f := (b + 8) / 25 + g := (b - f + 1) / 3 + h := (19*a + b - d - g + 15) % 30 + i := c / 4 + k := c % 4 + l := (32 + 2*e + 2*i - h - k) % 7 + m := (a + 11*h + 22*l) / 451 + month := (h + l - 7*m + 114) / 31 + day := ((h + l - 7*m + 114) % 31) + 1 + return month, day +} + +// GetHolidaysForYear returns all holidays for a year (for API exposure) +func (s *HolidayService) GetHolidaysForYear(year int) ([]Holiday, error) { + return s.LoadHolidaysForYear(year) +} + +// FormatHolidayList returns a simple string representation of holidays for debugging +func FormatHolidayList(holidays []Holiday) string { + var b strings.Builder + for _, h := range holidays { + fmt.Fprintf(&b, "%s: %s\n", h.Date.Format("2006-01-02"), h.Name) + } + return b.String() +} diff --git a/backend/internal/services/holidays_test.go b/backend/internal/services/holidays_test.go new file mode 100644 index 0000000..aa8acd3 --- /dev/null +++ b/backend/internal/services/holidays_test.go @@ -0,0 +1,121 @@ +package services + +import ( + "testing" + "time" +) + +func TestCalculateEasterSunday(t *testing.T) { + tests := []struct { + year int + wantMonth int + wantDay int + }{ + {2024, 3, 31}, + {2025, 4, 20}, + {2026, 4, 5}, + {2027, 3, 28}, + } + + for _, tt := range tests { + m, d := CalculateEasterSunday(tt.year) + if m != tt.wantMonth || d != tt.wantDay { + t.Errorf("CalculateEasterSunday(%d) = %d-%02d, want %d-%02d", + tt.year, m, d, tt.wantMonth, tt.wantDay) + } + } +} + +func TestGermanFederalHolidays(t *testing.T) { + holidays := germanFederalHolidays(2026) + + // Should have 11 federal holidays + if len(holidays) != 11 { + t.Fatalf("expected 11 federal holidays, got %d", len(holidays)) + } + + // Check Neujahr + if holidays[0].Name != "Neujahr" { + t.Errorf("first holiday should be Neujahr, got %s", holidays[0].Name) + } + if holidays[0].Date != time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) { + t.Errorf("Neujahr should be Jan 1, got %s", holidays[0].Date) + } + + // Check Karfreitag 2026 (Easter = Apr 5, so Good Friday = Apr 3) + found := false + for _, h := range holidays { + if h.Name == "Karfreitag" { + found = true + expected := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC) + if h.Date != expected { + t.Errorf("Karfreitag 2026 should be %s, got %s", expected, h.Date) + } + } + } + if !found { + t.Error("Karfreitag not found in holidays") + } +} + +func TestHolidayServiceIsNonWorkingDay(t *testing.T) { + svc := NewHolidayService(nil) // no DB, uses hardcoded holidays + + // Saturday + sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC) + if !svc.IsNonWorkingDay(sat) { + t.Error("Saturday should be non-working day") + } + + // Sunday + sun := time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC) + if !svc.IsNonWorkingDay(sun) { + t.Error("Sunday should be non-working day") + } + + // Regular Monday + mon := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC) + if svc.IsNonWorkingDay(mon) { + t.Error("regular Monday should be a working day") + } + + // Christmas (Friday Dec 25, 2026) + xmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC) + if !svc.IsNonWorkingDay(xmas) { + t.Error("Christmas should be non-working day") + } + + // New Year + newyear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if !svc.IsNonWorkingDay(newyear) { + t.Error("New Year should be non-working day") + } +} + +func TestAdjustForNonWorkingDays(t *testing.T) { + svc := NewHolidayService(nil) + + // Saturday -> Monday + sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC) + adj, orig, adjusted := svc.AdjustForNonWorkingDays(sat) + if !adjusted { + t.Error("Saturday should be adjusted") + } + if orig != sat { + t.Error("original should be unchanged") + } + expected := time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC) + if adj != expected { + t.Errorf("Saturday should adjust to Monday %s, got %s", expected, adj) + } + + // Regular Wednesday -> no adjustment + wed := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) + adj, _, adjusted = svc.AdjustForNonWorkingDays(wed) + if adjusted { + t.Error("Wednesday should not be adjusted") + } + if adj != wed { + t.Error("non-adjusted date should be unchanged") + } +}