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.
186 lines
5.2 KiB
Go
186 lines
5.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// GET /api/user-card-layouts — list the user's named card layouts (default first).
|
|
func handleListCardLayouts(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.cardLayout == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.cardLayout.List(r.Context(), uid)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
type createCardLayoutBody struct {
|
|
Name string `json:"name"`
|
|
Layout services.LayoutSpec `json:"layout"`
|
|
IsDefault bool `json:"is_default"`
|
|
}
|
|
|
|
// POST /api/user-card-layouts — create a new named layout.
|
|
func handleCreateCardLayout(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.cardLayout == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
|
return
|
|
}
|
|
var body createCardLayoutBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
|
return
|
|
}
|
|
row, err := dbSvc.cardLayout.Create(r.Context(), uid, services.CreateCardLayoutInput{
|
|
Name: body.Name,
|
|
Layout: body.Layout,
|
|
IsDefault: body.IsDefault,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrUserCardLayoutNameTaken) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "name already exists"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, row)
|
|
}
|
|
|
|
type updateCardLayoutBody struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Layout *services.LayoutSpec `json:"layout,omitempty"`
|
|
IsDefault *bool `json:"is_default,omitempty"`
|
|
}
|
|
|
|
// PATCH /api/user-card-layouts/{id} — partial update.
|
|
func handleUpdateCardLayout(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.cardLayout == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var body updateCardLayoutBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
|
return
|
|
}
|
|
row, err := dbSvc.cardLayout.Update(r.Context(), uid, id, services.UpdateCardLayoutInput{
|
|
Name: body.Name,
|
|
Layout: body.Layout,
|
|
IsDefault: body.IsDefault,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
if errors.Is(err, services.ErrUserCardLayoutNameTaken) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "name already exists"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, row)
|
|
}
|
|
|
|
// DELETE /api/user-card-layouts/{id} — remove a named layout. The active
|
|
// default cannot be deleted (return 409); the UI gates this.
|
|
func handleDeleteCardLayout(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.cardLayout == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
|
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.cardLayout.Delete(r.Context(), uid, id); err != nil {
|
|
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
if errors.Is(err, services.ErrUserCardLayoutDefaultGate) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete default layout — switch defaults first"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// POST /api/user-card-layouts/{id}/set-default — sugar over PATCH .{is_default:true}.
|
|
func handleSetDefaultCardLayout(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.cardLayout == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "card-layout service not configured"})
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
row, err := dbSvc.cardLayout.SetDefault(r.Context(), uid, id)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrUserCardLayoutNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, row)
|
|
}
|