package handlers import ( "encoding/json" "errors" "log" "net/http" "strconv" "github.com/google/uuid" "mgit.msbls.de/m/patholo/internal/auth" "mgit.msbls.de/m/patholo/internal/services" ) // dbServices bundles the Phase B services so handlers can stay thin. // Nil if DATABASE_URL was unset at startup. type dbServices struct { projects *services.ProjectService team *services.TeamService department *services.DepartmentService parties *services.PartyService deadline *services.DeadlineService appointment *services.AppointmentService caldav *services.CalDAVService rules *services.DeadlineRuleService calc *services.DeadlineCalculator users *services.UserService fristenrechner *services.FristenrechnerService dashboard *services.DashboardService note *services.NoteService checklistInst *services.ChecklistInstanceService mail *services.MailService invite *services.InviteService agenda *services.AgendaService } var dbSvc *dbServices // requireDB returns true if the DB-backed services are wired; otherwise // writes a 503 response and returns false. func requireDB(w http.ResponseWriter) bool { if dbSvc == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "database not configured — set DATABASE_URL on the server", }) return false } return true } // requireUser pulls the authenticated user UUID from the request context. func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]string{ "error": "authentication required", }) return uuid.Nil, false } return uid, true } // writeServiceError maps a services error to an HTTP status. func writeServiceError(w http.ResponseWriter, err error) { switch { case errors.Is(err, services.ErrNotVisible): writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) case errors.Is(err, services.ErrForbidden): writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) case errors.Is(err, services.ErrInvalidInput): writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) default: log.Printf("ERROR service: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) } } // GET /api/projects — list visible projects. // Query params: ?type=case&status=active&parent_id=&parent_null=1&search=foo func handleListProjects(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } q := r.URL.Query() filter := services.ProjectFilter{ Type: q.Get("type"), Status: q.Get("status"), Search: q.Get("search"), } if pidStr := q.Get("parent_id"); pidStr != "" { pid, err := uuid.Parse(pidStr) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid parent_id"}) return } filter.ParentID = &pid } if q.Get("parent_null") == "1" || q.Get("parent_null") == "true" { filter.ParentNullOnly = true } rows, err := dbSvc.projects.List(r.Context(), uid, filter) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, rows) } // POST /api/projects — also accepts the legacy POST /api/akten body shape // ({aktenzeichen, owning_office, court_ref}) for the frontend transition. // aktenzeichen → reference, court_ref → case_number, owning_office is dropped // (no longer part of the visibility model). Type defaults to 'case'. func handleCreateProject(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } // Parse into a loose map so we can accept both old and new shapes. var raw map[string]any if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } input := services.CreateProjektInput{ Type: services.ProjectTypeCase, } if v, ok := raw["type"].(string); ok && v != "" { input.Type = v } if v, ok := raw["title"].(string); ok { input.Title = v } // Legacy aktenzeichen → reference; new shape uses reference directly. if v, ok := raw["reference"].(string); ok && v != "" { input.Reference = &v } else if v, ok := raw["aktenzeichen"].(string); ok && v != "" { input.Reference = &v } if v, ok := raw["description"].(string); ok && v != "" { input.Description = &v } if v, ok := raw["status"].(string); ok { input.Status = v } if v, ok := raw["court"].(string); ok && v != "" { input.Court = &v } if v, ok := raw["case_number"].(string); ok && v != "" { input.CaseNumber = &v } else if v, ok := raw["court_ref"].(string); ok && v != "" { input.CaseNumber = &v } if v, ok := raw["parent_id"].(string); ok && v != "" { pid, err := uuid.Parse(v) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid parent_id"}) return } input.ParentID = &pid } if v, ok := raw["client_number"].(string); ok && v != "" { input.ClientNumber = &v } if v, ok := raw["matter_number"].(string); ok && v != "" { input.MatterNumber = &v } if v, ok := raw["netdocuments_url"].(string); ok && v != "" { input.NetDocumentsURL = &v } p, err := dbSvc.projects.Create(r.Context(), uid, input) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusCreated, p) } // GET /api/projects/{id} func handleGetProject(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } p, err := dbSvc.projects.GetByID(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, p) } // GET /api/projects/{id}/children — direct children. func handleListProjectChildren(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } rows, err := dbSvc.projects.ListChildren(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, rows) } // GET /api/projects/{id}/tree — full subtree depth-first (path-ordered). func handleGetProjectTree(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } rows, err := dbSvc.projects.GetTree(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, rows) } // GET /api/projects/{id}/ancestors — ancestor chain for breadcrumbs. func handleListProjectAncestors(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } rows, err := dbSvc.projects.ListAncestors(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, rows) } // PATCH /api/projects/{id} func handleUpdateProject(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } var input services.UpdateProjektInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } p, err := dbSvc.projects.Update(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, p) } // DELETE /api/projects/{id} func handleDeleteProject(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } if err := dbSvc.projects.Delete(r.Context(), uid, id); err != nil { writeServiceError(w, err) return } w.WriteHeader(http.StatusNoContent) } // GET /api/projects/{id}/events — audit trail with cursor pagination. func handleListProjectEvents(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } q := r.URL.Query() var before *uuid.UUID if b := q.Get("before"); b != "" { bu, err := uuid.Parse(b) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid before cursor"}) return } before = &bu } limit := 0 if l := q.Get("limit"); l != "" { n, err := strconv.Atoi(l) if err != nil || n < 0 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"}) return } limit = n } rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, rows) } // GET /api/projects/{id}/parties func handleListParties(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } rows, err := dbSvc.parties.ListForProjekt(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, rows) } // POST /api/projects/{id}/parties func handleCreateParty(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } id, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } var input services.CreateParteiInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } p, err := dbSvc.parties.Create(r.Context(), uid, id, input) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusCreated, p) } // DELETE /api/parties/{id} func handleDeleteParty(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } parteiID, err := uuid.Parse(r.PathValue("id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } if err := dbSvc.parties.Delete(r.Context(), uid, parteiID); err != nil { writeServiceError(w, err) return } w.WriteHeader(http.StatusNoContent) }