Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only; no user-visible change in A1. A2 (frontend) lands separately. What's new: - Migration 056: paliad.user_views table with RLS scoped to caller (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE (user_id, slug). No is_system flag — system defaults stay code- resident per Q8 lock-in. - internal/services/filter_spec.go (+test): structured FilterSpec with Sources / Scope / Time / Predicates. Server-side validator rejects unknown sources, duplicate sources, conflicting scope modes, horizon=all without explicit projects (Q26 clamp), and every per-source enum (deadline.status, appointment_types, project_event kinds, approval_request status / viewer_role). - internal/services/render_spec.go (+test): RenderSpec with three shapes (list / cards / calendar — Q4 lock-in 2026-05-07). Per-shape config kept separately so flipping shapes preserves tweaks. Validator over column / sort / density / group_by / default_view enums. - internal/services/system_views.go (+test): code-resident SystemView definitions for dashboard / agenda / events / inbox / inbox-mine. Reserved-slug list (Q23) prevents user-views from colliding with top-level URLs. Case-folded matching. - internal/services/view_service.go: extends EventService with RunSpec — runs a FilterSpec across all four substrate sources (deadline + appointment + project_event + approval_request) and merges into []ViewRow sorted by event_date. ViewRow is a discriminated projection (kind + common header + per-source Detail json.RawMessage). Q17 fail-open attribution: returns inaccessible_project_ids for explicit-scope queries where the caller can't see some IDs. - internal/services/user_view_service.go (+test): CRUD on paliad.user_views — Create (server-assigns sort_order MAX+1 in tx), GetBySlug, GetByID, Update (partial), Delete, Touch (last_used_at), MostRecent. Reserved-slug + slug-format validators on every write. - internal/handlers/views.go: nine HTTP handlers wiring the endpoints (GET/POST/PATCH/DELETE /api/user-views/..., POST /api/user-views/{id}/touch, POST /api/views/run, POST /api/views/{slug}/run, GET /api/views/system). - main.go + handlers.go + projects.go: wire UserViewService into the bundle; conditional route registration when both UserView + Event services are present. Pure-Go tests (no DB): 32 cases pass — filter spec validators, render spec validators, system view registry, reserved slugs. Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases covering create / list / get / uniqueness / update / delete / touch / most-recent / reserved-slug / bad-slug / empty-name / invalid-spec. Coexists with t-139 (in-flight on noether's other branch) and t-138 (shipped) without coordination commits — RunSpec uses the existing visibility predicate that t-139's migration 055 will extend with derivation. Approval-request source delegates to ApprovalService.ListPendingForApprover / ListSubmittedByUser (both already extended for derived_peer authority in t-139 Phase 3). Files: 15 changed, 3134 insertions. Build clean. Tests green.
466 lines
12 KiB
Go
466 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/auth"
|
|
"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
|
|
rules *services.DeadlineRuleService
|
|
calc *services.DeadlineCalculator
|
|
users *services.UserService
|
|
fristenrechner *services.FristenrechnerService
|
|
eventDeadline *services.EventDeadlineService
|
|
deadlineSearch *services.DeadlineSearchService
|
|
eventCategory *services.EventCategoryService
|
|
eventType *services.EventTypeService
|
|
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
|
|
event *services.EventService
|
|
courts *services.CourtService
|
|
approval *services.ApprovalService
|
|
derivation *services.DerivationService
|
|
userView *services.UserViewService
|
|
}
|
|
|
|
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()})
|
|
case errors.Is(err, services.ErrEventTypeSlugTaken):
|
|
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"})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|