feat: add deadline CRUD, calculator, and holiday services (Phase 1C)
This commit is contained in:
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"})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user