Files
paliad/internal/handlers/dashboard_layout.go
mAi 48f78a713b feat(dashboard): t-paliad-219 Slice A2 — HTTP handlers + service wiring
Four endpoints for the per-user dashboard layout:

- GET  /api/me/dashboard-layout         (auto-seeds factory on first call)
- PUT  /api/me/dashboard-layout         (validates against catalog)
- POST /api/me/dashboard-layout/reset   (overwrites with factory default)
- GET  /api/dashboard-widget-catalog    (catalog metadata for the picker)

Catalog endpoint is DB-independent by design — knowledge-platform-only
deployments (no DATABASE_URL) still surface the widget metadata. The
layout endpoints 503 when the service is unwired, matching the pattern
established by handleListCardLayouts / handleListPinnedProjects.

Wired through services.Services → handlers.dbServices via the
DashboardLayout field. main.go gains a single NewDashboardLayoutService
call next to NewCardLayoutService.

ErrInvalidInput from the service maps to 400; everything else flows
through writeServiceError for the existing 500/503 fallthrough.

go build + go vet + go test ./internal/services/ -short all clean.
2026-05-20 13:55:56 +02:00

110 lines
3.4 KiB
Go

package handlers
// HTTP handlers for the per-user dashboard layout (t-paliad-219 Slice A2).
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §9.
//
// Four endpoints:
// GET /api/me/dashboard-layout → read (auto-seeds factory default)
// PUT /api/me/dashboard-layout → replace (validates against catalog)
// POST /api/me/dashboard-layout/reset → overwrite with factory default
// GET /api/dashboard-widget-catalog → catalog metadata for the picker
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/me/dashboard-layout — returns the caller's layout, seeding the
// factory default on first call. Always returns 200 with a valid
// DashboardLayoutSpec.
func handleGetDashboardLayout(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, spec)
}
// PUT /api/me/dashboard-layout — replaces the caller's layout. Body must
// be a complete DashboardLayoutSpec; the service validates against the
// catalog and 400s on a bad spec.
func handlePutDashboardLayout(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
var spec services.DashboardLayoutSpec
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := dbSvc.dashboardLayout.Update(r.Context(), uid, spec)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// POST /api/me/dashboard-layout/reset — overwrites the caller's layout
// with the factory default. The previous layout is discarded.
func handleResetDashboardLayout(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
spec, err := dbSvc.dashboardLayout.ResetToDefault(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, spec)
}
// GET /api/dashboard-widget-catalog — returns the widget catalog. Auth-
// gated only because the catalog includes user-facing copy; nothing
// security-sensitive is exposed. The handler is DB-independent (the
// catalog is code-resident) so the requireDB gate is intentionally
// skipped — knowledge-platform-only deployments can still surface the
// catalog and we never want this endpoint to 503.
func handleGetWidgetCatalog(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
writeJSON(w, http.StatusOK, services.WidgetCatalog())
}