Files
paliad/internal/handlers/projects.go
m ce3227c1c0 refactor(t-paliad-080): service-layer naming sweep — Notiz/Termin/Frist/Projekt/Partei → Note/Appointment/Deadline/Project/Party
Mechanical rename across 8 service files plus their handler call sites and
two related helpers. The English types existed already; what changed are the
input-struct names, helper functions, list/create method suffixes, and
parameter names so they no longer mix English types with German parameter
names.

Renames cover:
- CreateNotizInput/UpdateNotizInput → CreateNoteInput/UpdateNoteInput,
  notizColumns/notizSelect → noteColumns/noteSelect, ListForProjekt/Frist/
  Termin → ListForProject/Deadline/Appointment, CreateForProjekt/Frist/
  Termin → CreateForProject/Deadline/Appointment, fristProjectID →
  deadlineProjectID
- CreateTerminInput/UpdateTerminInput → CreateAppointmentInput/
  UpdateAppointmentInput, terminColumns → appointmentColumns, ListForProjekt
  → ListForProject; parameter renames terminID → appointmentID, projektID
  → projectID
- CreateFristInput/UpdateFristInput → CreateDeadlineInput/
  UpdateDeadlineInput, fristColumns → deadlineColumns, ListForProjekt →
  ListForProject, isValidFristStatus → isValidDeadlineStatus; parameter
  renames fristID → deadlineID, projektID → projectID
- CreateProjektInput/UpdateProjektInput → CreateProjectInput/
  UpdateProjectInput, projektColumns → projectColumns,
  validateProjektStatus → validateProjectStatus, ProjektRole comment →
  ProjectRole
- CreateParteiInput → CreatePartyInput, parteiColumns → partyColumns,
  ListForProjekt → ListForProject; parameter renames parteiID → partyID
- OnTerminCreated/Updated/Deleted → OnAppointmentCreated/Updated/Deleted on
  the AppointmentCalDAVPusher interface and its CalDAVService impl
- formatTermin → formatAppointment in caldav_ical
- ListForProjekt → ListForProject, listWithProjekt → listWithProject,
  checklistInstanceWithProjektSelect → checklistInstanceWithProjectSelect,
  ClearProjekt → ClearProject (JSON tag clear_projekt unchanged — wire
  format)
- insertProjectEvent helper parameter projektID → projectID, error message
  "insert projekt_event" → "insert project_event"
- TeamService AddMember/RemoveMember/ListDirectMembers/ListEffectiveMembers
  parameter projektID → projectID; matching handler renames
- Frontend doc-comments referencing CreateProjektInput/UpdateProjektInput
  updated to CreateProjectInput/UpdateProjectInput

JSON wire tags (clear_projekt, etc.) and German user-facing strings
(glossary entries, search.go labels, email templates, changelog,
Terminsgebuehr, Fristenrechner product name) are intentionally untouched.

API contract unchanged. go build/vet/test ./... clean. Frontend bun build
clean.
2026-04-30 04:39:23 +02:00

454 lines
12 KiB
Go

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
partnerUnit *services.PartnerUnitService
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
audit *services.AuditService
emailTemplate *services.EmailTemplateService
link *services.LinkService
}
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=<uuid>&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.CreateProjectInput{
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/tree — nested tree of every visible Project. Each node
// carries open/overdue deadline counts and embedded children so the UI can
// render the full hierarchy in one round-trip. Visibility-scoped.
func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
tree, err := dbSvc.projects.BuildTree(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, tree)
}
// 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.UpdateProjectInput
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.ListForProject(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.CreatePartyInput
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
}
partyID, 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, partyID); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}