package handlers import ( "encoding/json" "errors" "log" "net/http" "strconv" "strings" "github.com/google/uuid" "mgit.msbls.de/m/paliad/internal/auth" "mgit.msbls.de/m/paliad/internal/models" "mgit.msbls.de/m/paliad/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 caldavBindings *services.CalendarBindingService rules *services.DeadlineRuleService calc *services.DeadlineCalculator users *services.UserService fristenrechner *services.FristenrechnerService eventDeadline *services.EventDeadlineService eventTrigger *services.EventTriggerService ruleEditor *services.RuleEditorService deadlineSearch *services.DeadlineSearchService eventCategory *services.EventCategoryService eventType *services.EventTypeService dashboard *services.DashboardService note *services.NoteService checklistInst *services.ChecklistInstanceService checklistCatalog *services.ChecklistCatalogService checklistTemplate *services.ChecklistTemplateService checklistShare *services.ChecklistShareService checklistPromotion *services.ChecklistPromotionService mail *services.MailService invite *services.InviteService agenda *services.AgendaService audit *services.AuditService emailTemplate *services.EmailTemplateService link *services.LinkService event *services.EventService courts *services.CourtService approval *services.ApprovalService derivation *services.DerivationService userView *services.UserViewService broadcast *services.BroadcastService pin *services.PinService cardLayout *services.CardLayoutService dashboardLayout *services.DashboardLayoutService firmDashboardDefault *services.FirmDashboardDefaultService projection *services.ProjectionService export *services.ExportService // t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or // PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503). backup *services.BackupRunner // t-paliad-238 — submission draft editor. submissionDraft *services.SubmissionDraftService // t-paliad-265 — per-event-card optional choices. eventChoice *services.EventChoiceService // Slice D — named scenario compositions (m/paliad#124 §5). scenario *services.ScenarioService } 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) { if mapApprovalError(w, err) { return } 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()}) case errors.Is(err, services.ErrInvalidProceedingTypeCategory): // Phase 3 Slice 5 (t-paliad-186). Bilingual user-facing message // matches what the project-form copy expects so the toast reads // naturally without an i18n round-trip in the handler. writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "Verfahrenstyp muss ein Fristenrechner-Typ sein / proceeding type must be a Fristenrechner type", }) case errors.Is(err, services.ErrEventTypeSlugTaken): writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) case errors.Is(err, services.ErrLastProjectAdmin): writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) default: log.Printf("ERROR service: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) } } // mapApprovalError handles approval-flow errors that bubble through the // shared writeServiceError path (entity mutation handlers — deadlines, // appointments — go through writeServiceError, not writeApprovalError). // // Returns true iff err matched an approval-flow case and the response // has been written. False = caller should keep walking the switch. // // Response shape (t-paliad-160 §B): // // { // code: "awaiting_approval" | "no_qualified_approver" | ..., // message: "", // request_id?: "", // present when known // required_role?: "", // present when known // } func mapApprovalError(w http.ResponseWriter, err error) bool { var pendingErr *services.PendingApprovalError if errors.As(err, &pendingErr) { body := map[string]string{ "code": "awaiting_approval", "message": "Diese Anforderung wartet auf Genehmigung.", } if pendingErr.RequestID != "" { body["request_id"] = pendingErr.RequestID } if pendingErr.RequiredRole != "" { body["required_role"] = pendingErr.RequiredRole } writeJSON(w, http.StatusConflict, body) return true } switch { case errors.Is(err, services.ErrConcurrentPending): writeJSON(w, http.StatusConflict, map[string]string{ "code": "awaiting_approval", "message": "Diese Anforderung wartet auf Genehmigung.", }) return true case errors.Is(err, services.ErrNoQualifiedApprover): writeJSON(w, http.StatusConflict, map[string]string{ "code": "no_qualified_approver", "message": "Es gibt keinen anderen Benutzer, der diese Anfrage genehmigen kann.", }) return true case errors.Is(err, services.ErrSelfApproval): writeJSON(w, http.StatusForbidden, map[string]string{ "code": "self_approval_blocked", "message": "Selbst-Genehmigung ist nicht erlaubt.", }) return true case errors.Is(err, services.ErrNotApprover): writeJSON(w, http.StatusForbidden, map[string]string{ "code": "not_authorized", "message": "Sie sind für diese Genehmigung nicht berechtigt.", }) return true case errors.Is(err, services.ErrRequestNotPending): writeJSON(w, http.StatusConflict, map[string]string{ "code": "request_not_pending", "message": "Die Anfrage ist nicht mehr offen.", }) return true case errors.Is(err, services.ErrSuggestionRequiresChange): writeJSON(w, http.StatusBadRequest, map[string]string{ "code": "suggestion_requires_change", "message": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.", }) return true case errors.Is(err, services.ErrSuggestionLifecycleInvalid): writeJSON(w, http.StatusBadRequest, map[string]string{ "code": "suggestion_lifecycle_invalid", "message": "Änderungen vorschlagen ist nur für Update- und Complete-Anfragen möglich.", }) return true } return false } // 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.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 } if v, ok := raw["instance_level"].(string); ok { // Empty string is the explicit "clear" sentinel for the // service layer (nullableInstanceLevel writes NULL). input.InstanceLevel = &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 } // t-paliad-223: piggyback effective_project_admin onto the project // payload so the frontend can drive the inline role-edit affordance // without a second round-trip. JSON-merge via a small wrapper that // embeds the existing Project shape — every existing caller keeps // reading the same fields and gains effective_admin as additive. effAdmin, err := dbSvc.team.IsEffectiveProjectAdmin(r.Context(), uid, id) if err != nil { writeServiceError(w, err) return } type projectWithPermissions struct { *models.Project EffectiveAdmin bool `json:"effective_admin"` } writeJSON(w, http.StatusOK, projectWithPermissions{ Project: p, EffectiveAdmin: effAdmin, }) } // 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. // // Query parameters (all optional, additive): // ?scope=all|mine|pinned — chip-driven scope (default "all") // ?status=active,archived,closed — status whitelist (CSV; default = no narrowing) // ?type=client,litigation,patent,case,project,other — type whitelist // ?has_open_deadlines=true|false — narrow by deadline activity // ?q= — search title / reference / clientmatter // ?subtree_counts=true|false — populate *_subtree fields (default true) // // Zero query string preserves the legacy behaviour for back-compat (existing // callers that just want every visible project). func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } q := r.URL.Query() opts := services.BuildTreeOptions{ IncludeSubtreeCounts: parseBoolQuery(q.Get("subtree_counts"), true), SearchTerm: q.Get("q"), StatusIn: splitCSV(q.Get("status")), TypeIn: splitCSV(q.Get("type")), } switch q.Get("scope") { case "mine": opts.Scope = services.ScopeMine case "pinned": opts.Scope = services.ScopePinned } if v := q.Get("has_open_deadlines"); v != "" { b := parseBoolQuery(v, false) opts.HasOpenDeadlines = &b } // Pin set is needed when the response carries `pinned: bool` per node // (always, when PinService is wired) AND when scope=pinned narrows. if dbSvc.pin != nil { set, err := dbSvc.pin.PinnedSet(r.Context(), uid) if err != nil { writeServiceError(w, err) return } opts.PinnedSet = set } else if opts.Scope == services.ScopePinned { // scope=pinned without PinService can never have hits. writeJSON(w, http.StatusOK, []any{}) return } tree, err := dbSvc.projects.BuildTreeWithOptions(r.Context(), uid, opts) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, tree) } // parseBoolQuery accepts true/false/1/0/yes/no/on/off (case-insensitive). // Falls back to def for empty / unrecognised input. func parseBoolQuery(v string, def bool) bool { switch v { case "true", "1", "yes", "on": return true case "false", "0", "no", "off": return false default: return def } } // GET /api/projects/cards-preview — per-project event rollups for the // Cards view. Returns a flat list of {project_id, next_events, // recent_verlauf, team_initials, team_count, last_activity_at} for every // project the user can see (or the subset given via ?ids=). // // Visibility-scoped server-side. Caller (Cards mode) lazy-fetches batches // via IntersectionObserver. func handleProjectsCardsPreview(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } var ids []uuid.UUID if raw := r.URL.Query().Get("ids"); raw != "" { for _, s := range splitCSV(raw) { u, err := uuid.Parse(s) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid uuid in ?ids"}) return } ids = append(ids, u) } } previews, err := dbSvc.projects.CardsPreview(r.Context(), uid, ids) if err != nil { writeServiceError(w, err) return } // Flat array for JSON; the map order is irrelevant to the client (it // keys on project_id when stitching to its tree-id list). out := make([]*services.ProjectCardPreview, 0, len(previews)) for _, p := range previews { out = append(out, p) } writeJSON(w, http.StatusOK, out) } // splitCSV splits a comma-separated query value into trimmed non-empty // tokens. Empty input → nil so callers can branch on `len(out) > 0`. func splitCSV(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } if len(out) == 0 { return nil } return out } // 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 } directOnly := parseDirectOnly(q.Get("direct_only")) rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit, directOnly) 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) } // GET /api/parties/search?q=... // // Cross-project party picker for the submission-draft editor // (t-paliad-287). Returns up to 25 parties from every project the // caller can see, matched by case-insensitive substring on name or // representative. Empty q returns the 20 most-recently-updated rows so // the picker isn't blank on first open. Visibility is enforced in the // service layer via the same predicate every project-scoped read uses. func handlePartiesSearch(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } q := r.URL.Query().Get("q") hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"results": hits}) } // 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) }