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.
311 lines
10 KiB
Go
311 lines
10 KiB
Go
package services
|
|
|
|
// CardLayoutService is the CRUD layer for paliad.user_card_layouts —
|
|
// per-user named card layouts for the /projects Cards view.
|
|
//
|
|
// Design: docs/design-projects-page-2026-05-07.md §5b.3.
|
|
//
|
|
// Visibility: every read and write is scoped to the calling user via the
|
|
// RLS policy `user_card_layouts_owner_all` on auth.uid() = user_id. The
|
|
// service also AND-joins user_id in the SQL for defense-in-depth (RLS
|
|
// can be disabled in tests, the code-level check still holds).
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// UserCardLayout is the persisted shape of a saved card layout.
|
|
type UserCardLayout struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
|
Name string `db:"name" json:"name"`
|
|
IsDefault bool `db:"is_default" json:"is_default"`
|
|
LayoutJSON json.RawMessage `db:"layout_json" json:"layout"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// CardLayoutService manages paliad.user_card_layouts.
|
|
type CardLayoutService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// NewCardLayoutService wires the service.
|
|
func NewCardLayoutService(db *sqlx.DB) *CardLayoutService {
|
|
return &CardLayoutService{db: db}
|
|
}
|
|
|
|
// ErrUserCardLayoutNameTaken signals "name already exists for this user".
|
|
// HTTP layer maps to 409.
|
|
var ErrUserCardLayoutNameTaken = errors.New("card layout name taken")
|
|
|
|
// ErrUserCardLayoutNotFound signals "no row matches (id, user_id)". HTTP
|
|
// layer maps to 404.
|
|
var ErrUserCardLayoutNotFound = errors.New("card layout not found")
|
|
|
|
// ErrUserCardLayoutDefaultGate signals "cannot delete the active default
|
|
// layout — switch defaults first." HTTP layer maps to 409.
|
|
var ErrUserCardLayoutDefaultGate = errors.New("cannot delete default card layout")
|
|
|
|
// CreateInput is the payload for Create.
|
|
type CreateCardLayoutInput struct {
|
|
Name string
|
|
Layout LayoutSpec
|
|
IsDefault bool // first layout per user is implicitly the default
|
|
}
|
|
|
|
// UpdateInput is the partial-update payload. All fields nil = no change.
|
|
type UpdateCardLayoutInput struct {
|
|
Name *string
|
|
Layout *LayoutSpec
|
|
IsDefault *bool
|
|
}
|
|
|
|
// List returns the user's layouts in name order, default first.
|
|
func (s *CardLayoutService) List(ctx context.Context, userID uuid.UUID) ([]UserCardLayout, error) {
|
|
out := []UserCardLayout{}
|
|
err := s.db.SelectContext(ctx, &out, `
|
|
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
|
FROM paliad.user_card_layouts
|
|
WHERE user_id = $1
|
|
ORDER BY is_default DESC, name ASC
|
|
`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list card layouts: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Get returns one layout by id (gated on user_id).
|
|
func (s *CardLayoutService) Get(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
|
|
var l UserCardLayout
|
|
err := s.db.GetContext(ctx, &l, `
|
|
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
|
FROM paliad.user_card_layouts
|
|
WHERE id = $1 AND user_id = $2
|
|
`, id, userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrUserCardLayoutNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get card layout: %w", err)
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// GetDefault returns the user's default layout. Auto-seeds the seed
|
|
// "Standard" layout (DefaultLayoutSpec) on the first call so callers can
|
|
// always treat it as never-failing for read-only paths.
|
|
func (s *CardLayoutService) GetDefault(ctx context.Context, userID uuid.UUID) (*UserCardLayout, error) {
|
|
var l UserCardLayout
|
|
err := s.db.GetContext(ctx, &l, `
|
|
SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
|
|
FROM paliad.user_card_layouts
|
|
WHERE user_id = $1 AND is_default = true
|
|
`, userID)
|
|
if err == nil {
|
|
return &l, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("get default card layout: %w", err)
|
|
}
|
|
// First-ever call for this user — seed the Standard layout.
|
|
return s.Create(ctx, userID, CreateCardLayoutInput{
|
|
Name: "Standard",
|
|
Layout: DefaultLayoutSpec(),
|
|
IsDefault: true,
|
|
})
|
|
}
|
|
|
|
// Create writes a new layout. If the user has no rows yet, the new row
|
|
// becomes the default regardless of input.IsDefault. If input.IsDefault
|
|
// is true and another default exists, the previous default's flag is
|
|
// cleared in the same transaction.
|
|
func (s *CardLayoutService) Create(ctx context.Context, userID uuid.UUID, in CreateCardLayoutInput) (*UserCardLayout, error) {
|
|
if err := validateLayoutName(in.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := in.Layout.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
layoutBytes, err := json.Marshal(in.Layout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create card layout begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck — Commit() supersedes; Rollback() on success is a no-op
|
|
|
|
var existingCount int
|
|
if err := tx.GetContext(ctx, &existingCount, `
|
|
SELECT COUNT(*) FROM paliad.user_card_layouts WHERE user_id = $1
|
|
`, userID); err != nil {
|
|
return nil, fmt.Errorf("count existing layouts: %w", err)
|
|
}
|
|
wantDefault := in.IsDefault || existingCount == 0
|
|
if wantDefault {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
|
|
WHERE user_id = $1 AND is_default = true
|
|
`, userID); err != nil {
|
|
return nil, fmt.Errorf("clear prior default: %w", err)
|
|
}
|
|
}
|
|
|
|
var l UserCardLayout
|
|
err = tx.GetContext(ctx, &l, `
|
|
INSERT INTO paliad.user_card_layouts (user_id, name, is_default, layout_json)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
|
|
`, userID, in.Name, wantDefault, json.RawMessage(layoutBytes))
|
|
if err != nil {
|
|
if isPgUniqueViolation(err) {
|
|
return nil, ErrUserCardLayoutNameTaken
|
|
}
|
|
return nil, fmt.Errorf("insert card layout: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("create card layout commit: %w", err)
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// Update writes one or more partial fields. is_default=true flips the
|
|
// user's default in a transaction.
|
|
func (s *CardLayoutService) Update(ctx context.Context, userID, id uuid.UUID, in UpdateCardLayoutInput) (*UserCardLayout, error) {
|
|
// First ensure the row exists + is owned.
|
|
if _, err := s.Get(ctx, userID, id); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if in.Name != nil {
|
|
if err := validateLayoutName(*in.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if in.Layout != nil {
|
|
if err := in.Layout.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update card layout begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
if in.IsDefault != nil && *in.IsDefault {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
|
|
WHERE user_id = $1 AND is_default = true AND id <> $2
|
|
`, userID, id); err != nil {
|
|
return nil, fmt.Errorf("clear prior default: %w", err)
|
|
}
|
|
}
|
|
|
|
sets := []string{"updated_at = now()"}
|
|
args := []any{userID, id}
|
|
if in.Name != nil {
|
|
sets = append(sets, fmt.Sprintf("name = $%d", len(args)+1))
|
|
args = append(args, *in.Name)
|
|
}
|
|
if in.Layout != nil {
|
|
layoutBytes, err := json.Marshal(*in.Layout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
|
|
}
|
|
sets = append(sets, fmt.Sprintf("layout_json = $%d", len(args)+1))
|
|
args = append(args, json.RawMessage(layoutBytes))
|
|
}
|
|
if in.IsDefault != nil {
|
|
sets = append(sets, fmt.Sprintf("is_default = $%d", len(args)+1))
|
|
args = append(args, *in.IsDefault)
|
|
}
|
|
|
|
q := fmt.Sprintf(`
|
|
UPDATE paliad.user_card_layouts SET %s
|
|
WHERE user_id = $1 AND id = $2
|
|
RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
|
|
`, strings.Join(sets, ", "))
|
|
|
|
var l UserCardLayout
|
|
err = tx.GetContext(ctx, &l, q, args...)
|
|
if err != nil {
|
|
if isPgUniqueViolation(err) {
|
|
return nil, ErrUserCardLayoutNameTaken
|
|
}
|
|
return nil, fmt.Errorf("update card layout: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("update card layout commit: %w", err)
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// SetDefault is sugar over Update with only IsDefault set. It also flips
|
|
// the prior default in the same transaction.
|
|
func (s *CardLayoutService) SetDefault(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
|
|
def := true
|
|
return s.Update(ctx, userID, id, UpdateCardLayoutInput{IsDefault: &def})
|
|
}
|
|
|
|
// Delete removes a layout. Cannot delete the active default — UI gates
|
|
// this; the service returns ErrUserCardLayoutDefaultGate as a backstop.
|
|
func (s *CardLayoutService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
|
row, err := s.Get(ctx, userID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if row.IsDefault {
|
|
return ErrUserCardLayoutDefaultGate
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
DELETE FROM paliad.user_card_layouts
|
|
WHERE id = $1 AND user_id = $2
|
|
`, id, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete card layout: %w", err)
|
|
}
|
|
rows, _ := res.RowsAffected()
|
|
if rows == 0 {
|
|
return ErrUserCardLayoutNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateLayoutName mirrors the column's NOT NULL + a sane length cap so
|
|
// the UI dropdown doesn't have 200-char names breaking the layout. Named
|
|
// distinctly from user_view_service.validateName because the user_view
|
|
// rules differ (they enforce a slug regex separately).
|
|
func validateLayoutName(name string) error {
|
|
if strings.TrimSpace(name) == "" {
|
|
return fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if len([]rune(name)) > 80 {
|
|
return fmt.Errorf("%w: name exceeds 80 characters", ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isPgUniqueViolation reports whether err is a Postgres unique-violation
|
|
// (SQLSTATE 23505). Used to map "duplicate (user_id, name)" to a clean
|
|
// 409 ErrUserCardLayoutNameTaken.
|
|
func isPgUniqueViolation(err error) bool {
|
|
var pgErr *pq.Error
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
}
|