Compare commits
1 Commits
mai/ritchi
...
mai/pike/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a62d45bf |
89
backend/internal/handlers/calculate.go
Normal file
89
backend/internal/handlers/calculate.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
backend/internal/handlers/deadline_rules.go
Normal file
58
backend/internal/handlers/deadline_rules.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
162
backend/internal/handlers/deadlines.go
Normal file
162
backend/internal/handlers/deadlines.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
85
backend/internal/handlers/helpers.go
Normal file
85
backend/internal/handlers/helpers.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -4,12 +4,25 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
||||||
|
// Services
|
||||||
|
holidaySvc := services.NewHolidayService(db)
|
||||||
|
deadlineSvc := services.NewDeadlineService(db)
|
||||||
|
deadlineRuleSvc := services.NewDeadlineRuleService(db)
|
||||||
|
calculator := services.NewDeadlineCalculator(holidaySvc)
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
|
||||||
|
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
||||||
|
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
@@ -17,8 +30,23 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
|||||||
|
|
||||||
// Authenticated API routes
|
// Authenticated API routes
|
||||||
api := http.NewServeMux()
|
api := http.NewServeMux()
|
||||||
|
|
||||||
|
// Deadline CRUD (case-scoped)
|
||||||
|
api.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
|
||||||
|
api.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
|
||||||
|
api.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
|
||||||
|
api.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
|
||||||
|
api.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
|
||||||
|
|
||||||
|
// Deadline rules (public reference data, but behind auth)
|
||||||
|
api.HandleFunc("GET /api/deadline-rules", ruleH.List)
|
||||||
|
api.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
|
||||||
|
|
||||||
|
// Deadline calculator
|
||||||
|
api.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
|
||||||
|
|
||||||
|
// Placeholder routes (not yet implemented)
|
||||||
api.HandleFunc("GET /api/cases", placeholder("cases"))
|
api.HandleFunc("GET /api/cases", placeholder("cases"))
|
||||||
api.HandleFunc("GET /api/deadlines", placeholder("deadlines"))
|
|
||||||
api.HandleFunc("GET /api/appointments", placeholder("appointments"))
|
api.HandleFunc("GET /api/appointments", placeholder("appointments"))
|
||||||
api.HandleFunc("GET /api/documents", placeholder("documents"))
|
api.HandleFunc("GET /api/documents", placeholder("documents"))
|
||||||
|
|
||||||
|
|||||||
99
backend/internal/services/deadline_calculator.go
Normal file
99
backend/internal/services/deadline_calculator.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
141
backend/internal/services/deadline_calculator_test.go
Normal file
141
backend/internal/services/deadline_calculator_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
175
backend/internal/services/deadline_rule_service.go
Normal file
175
backend/internal/services/deadline_rule_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
180
backend/internal/services/deadline_service.go
Normal file
180
backend/internal/services/deadline_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
193
backend/internal/services/holidays.go
Normal file
193
backend/internal/services/holidays.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
121
backend/internal/services/holidays_test.go
Normal file
121
backend/internal/services/holidays_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user