Files
paliad/internal/handlers/projects.go
m 4e1d311a9c feat(t-paliad-149) PR2 step 1/3: backend — migration 061 + CardLayoutService + CardsPreview
Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
  one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
  labels.
- RLS owner-only (mirrors paliad.user_views from t-144).

LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
  ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}

DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).

CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.

ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.

Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).

Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.

Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
  title-row-first invariant, hidden leading allowed, dup-key rejection,
  unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
  enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
  + idempotent, first Create auto-becomes default, SetDefault clears
  prior, Delete refuses active default, Delete non-default works,
  duplicate name rejected, Update round-trips layout JSON.

go build / vet / test (short) clean.

Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
2026-05-07 22:41:18 +02:00

590 lines
16 KiB
Go

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/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
broadcast *services.BroadcastService
pin *services.PinService
cardLayout *services.CardLayoutService
}
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.
//
// 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 — type whitelist
// ?has_open_deadlines=true|false — narrow by deadline activity
// ?q=<term> — 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=<csv-of-uuids>).
//
// 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)
}
// 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)
}