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.
This commit is contained in:
m
2026-05-07 22:41:18 +02:00
parent 1061685981
commit 4e1d311a9c
11 changed files with 1503 additions and 0 deletions

View File

@@ -162,6 +162,7 @@ func main() {
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
CardLayout: services.NewCardLayoutService(pool),
}
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL

View File

@@ -0,0 +1,3 @@
-- Reverse of 061_user_card_layouts.up.sql.
DROP TABLE IF EXISTS paliad.user_card_layouts;

View File

@@ -0,0 +1,76 @@
-- t-paliad-149 PR 2: per-user named card layouts.
--
-- Design: docs/design-projects-page-2026-05-07.md §5b.3 (godel,
-- m-locked 2026-05-07: full drag-rearrange + named layouts).
--
-- Stores per-user named card-layout definitions for the /projects Cards
-- view. A layout is a `(facts[], density, gridColumns, showAllLevels)`
-- bundle plus a name and the is_default flag.
--
-- The very first time a user opens Cards view, the application layer
-- auto-seeds a "Standard" layout (the rich content set per design §5b.4)
-- and flips its is_default=true. From there the user can rename, create
-- new layouts, drag facts around, switch defaults, and delete (except
-- the active default — UI gates).
--
-- RLS scopes every operation to the calling user; layouts are personal
-- working state (no firm-wide / cross-user visibility v1). Partial unique
-- index keeps "at most one default per user" honest at the DB level even
-- if the application layer's tx-flip-default ever races.
-- ============================================================================
-- 1. paliad.user_card_layouts
-- ============================================================================
CREATE TABLE paliad.user_card_layouts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
-- Display name. Free-form; user picks the language they think in.
-- Renders verbatim in the layout dropdown; no translation.
name text NOT NULL,
-- Exactly one default per user, enforced via partial unique index below.
-- Application layer flips this in a transaction (clear old, set new).
is_default boolean NOT NULL DEFAULT false,
-- Layout JSON — see internal/services/layout_spec.go LayoutSpec.
-- Validated on write; jsonb here for forward-compat without migrations
-- as new fact keys land.
layout_json jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Names are unique per user so the layout dropdown can use names as
-- stable labels and the application layer can return ErrUserCardLayoutNameTaken.
UNIQUE (user_id, name)
);
-- ============================================================================
-- 2. Indexes
-- ============================================================================
-- Hot path: list a user's layouts in name order.
CREATE INDEX user_card_layouts_user_idx
ON paliad.user_card_layouts (user_id, name);
-- Partial unique index: at most one default layout per user. Keeps the
-- invariant honest even if two concurrent PATCH .../set-default calls land
-- (the second one's UPDATE will conflict, the application layer retries).
CREATE UNIQUE INDEX user_card_layouts_default_idx
ON paliad.user_card_layouts (user_id)
WHERE is_default = true;
-- ============================================================================
-- 3. RLS
-- ============================================================================
ALTER TABLE paliad.user_card_layouts ENABLE ROW LEVEL SECURITY;
-- Owner-only access. No global_admin override (mirrors paliad.user_views;
-- card layouts are personal working state, not auditable infrastructure).
CREATE POLICY user_card_layouts_owner_all
ON paliad.user_card_layouts FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());

View File

@@ -0,0 +1,185 @@
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)
}

View File

@@ -67,6 +67,7 @@ type Services struct {
UserView *services.UserViewService
Broadcast *services.BroadcastService
Pin *services.PinService
CardLayout *services.CardLayoutService
// Paliadin is wired only when PALIADIN_ENABLED=true at boot
// (PoC; m's laptop only). On prod it stays nil and all /paliadin*
@@ -115,6 +116,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
userView: svc.UserView,
broadcast: svc.Broadcast,
pin: svc.Pin,
cardLayout: svc.CardLayout,
}
}
@@ -215,6 +217,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject)
protected.HandleFunc("DELETE /api/projects/{id}/pin", handleUnpinProject)
protected.HandleFunc("GET /api/user-pinned-projects", handleListPinnedProjects)
protected.HandleFunc("GET /api/projects/cards-preview", handleProjectsCardsPreview)
protected.HandleFunc("GET /api/user-card-layouts", handleListCardLayouts)
protected.HandleFunc("POST /api/user-card-layouts", handleCreateCardLayout)
protected.HandleFunc("PATCH /api/user-card-layouts/{id}", handleUpdateCardLayout)
protected.HandleFunc("DELETE /api/user-card-layouts/{id}", handleDeleteCardLayout)
protected.HandleFunc("POST /api/user-card-layouts/{id}/set-default", handleSetDefaultCardLayout)
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)

View File

@@ -48,6 +48,7 @@ type dbServices struct {
userView *services.UserViewService
broadcast *services.BroadcastService
pin *services.PinService
cardLayout *services.CardLayoutService
}
var dbSvc *dbServices
@@ -321,6 +322,48 @@ func parseBoolQuery(v string, def bool) bool {
}
}
// 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 {

View File

@@ -0,0 +1,310 @@
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"
}

View File

@@ -0,0 +1,231 @@
package services
// Live-DB tests for CardLayoutService. Skipped when TEST_DATABASE_URL
// is unset.
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
type cardLayoutTestEnv struct {
t *testing.T
pool *sqlx.DB
svc *CardLayoutService
userID uuid.UUID
cleanup func()
}
func setupCardLayoutTest(t *testing.T) *cardLayoutTestEnv {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
ctx := context.Background()
userID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Logf("skip auth.users seed: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'Card Layout Test', 'munich', 'standard')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
cleanup := func() {
c := context.Background()
pool.ExecContext(c, `DELETE FROM paliad.user_card_layouts WHERE user_id = $1`, userID)
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(c, `DELETE FROM auth.users WHERE id = $1`, userID)
pool.Close()
}
return &cardLayoutTestEnv{
t: t,
pool: pool,
svc: NewCardLayoutService(pool),
userID: userID,
cleanup: cleanup,
}
}
func TestCardLayoutService_GetDefaultAutoSeeds(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
// First call seeds the default.
def, err := env.svc.GetDefault(ctx, env.userID)
if err != nil {
t.Fatalf("GetDefault: %v", err)
}
if def.Name != "Standard" || !def.IsDefault {
t.Errorf("seeded default: name=%q is_default=%v; want Standard, true", def.Name, def.IsDefault)
}
// Second call returns the same row, not a new seed.
def2, err := env.svc.GetDefault(ctx, env.userID)
if err != nil {
t.Fatalf("GetDefault second: %v", err)
}
if def2.ID != def.ID {
t.Errorf("second GetDefault returned %v; want same id %v", def2.ID, def.ID)
}
}
func TestCardLayoutService_FirstCreateBecomesDefault(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{
Name: "Mein Erstes",
Layout: DefaultLayoutSpec(),
IsDefault: false, // even with false, first row becomes default.
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if !row.IsDefault {
t.Errorf("first layout is_default=false; want true (auto-flip)")
}
}
func TestCardLayoutService_SetDefaultClearsPrior(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
a, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "A", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create A: %v", err)
}
if !a.IsDefault {
t.Fatalf("A is_default=false; want true")
}
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "B", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create B: %v", err)
}
if b.IsDefault {
t.Fatalf("B is_default=true; want false (A is still default)")
}
// Flip B → default.
bAfter, err := env.svc.SetDefault(ctx, env.userID, b.ID)
if err != nil {
t.Fatalf("SetDefault B: %v", err)
}
if !bAfter.IsDefault {
t.Errorf("after SetDefault B.is_default=false")
}
// A should no longer be default.
aAfter, err := env.svc.Get(ctx, env.userID, a.ID)
if err != nil {
t.Fatalf("Get A: %v", err)
}
if aAfter.IsDefault {
t.Errorf("A still is_default=true after B took the flag")
}
}
func TestCardLayoutService_DeleteRefusesActiveDefault(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "OnlyOne", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create: %v", err)
}
err = env.svc.Delete(ctx, env.userID, row.ID)
if !errors.Is(err, ErrUserCardLayoutDefaultGate) {
t.Errorf("Delete default = %v; want ErrUserCardLayoutDefaultGate", err)
}
}
func TestCardLayoutService_DeleteNonDefault(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
_, _ = env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Default", Layout: DefaultLayoutSpec()})
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Throwaway", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create throwaway: %v", err)
}
if err := env.svc.Delete(ctx, env.userID, b.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := env.svc.Get(ctx, env.userID, b.ID); !errors.Is(err, ErrUserCardLayoutNotFound) {
t.Errorf("Get after delete = %v; want ErrUserCardLayoutNotFound", err)
}
}
func TestCardLayoutService_DuplicateNameRejected(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
if _, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()}); err != nil {
t.Fatalf("Create first: %v", err)
}
_, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()})
if !errors.Is(err, ErrUserCardLayoutNameTaken) {
t.Errorf("duplicate name = %v; want ErrUserCardLayoutNameTaken", err)
}
}
func TestCardLayoutService_UpdateRoundTrip(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Editable", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create: %v", err)
}
newName := "Renamed"
newLayout := DefaultLayoutSpec()
newLayout.Density = CardDensityCompact
updated, err := env.svc.Update(ctx, env.userID, row.ID, UpdateCardLayoutInput{
Name: &newName,
Layout: &newLayout,
})
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.Name != "Renamed" {
t.Errorf("name = %q; want Renamed", updated.Name)
}
parsed, err := ParseLayoutSpec(updated.LayoutJSON)
if err != nil {
t.Fatalf("ParseLayoutSpec on updated: %v", err)
}
if parsed.Density != CardDensityCompact {
t.Errorf("density round-trip = %q; want compact", parsed.Density)
}
}

View File

@@ -0,0 +1,190 @@
package services
// LayoutSpec — JSON shape for paliad.user_card_layouts.layout_json.
//
// Design: docs/design-projects-page-2026-05-07.md §5b.3.
//
// Validation surface (server-side):
// - Every fact key must be in KnownFactKeys.
// - Each key appears at most once (no duplicates).
// - "title-row" must be the first visible fact (always-on, structural).
// - count is bounded [1, 5] when set (only meaningful for next-events /
// recent-verlauf).
// - density ∈ {compact, roomy}.
// - gridColumns ∈ {auto, 2, 3, 4}.
//
// JSON shape mirrors the TypeScript type in
// frontend/src/client/projects-cards-types.ts.
import (
"encoding/json"
"fmt"
"slices"
)
// FactKey enumerates the cards facts the user can show / hide / reorder.
type FactKey string
const (
FactTitleRow FactKey = "title-row"
FactTypeChip FactKey = "type-chip"
FactStatusChip FactKey = "status-chip"
FactClientMatter FactKey = "client-matter"
FactParentPath FactKey = "parent-path"
FactDeadlineCounts FactKey = "deadline-counts"
FactNextEvents FactKey = "next-events"
FactRecentVerlauf FactKey = "recent-verlauf"
FactTeamChips FactKey = "team-chips"
FactReference FactKey = "reference"
FactLastActivityAt FactKey = "last-activity-at"
)
// KnownFactKeys is the registry. Adding a new fact = add a const above
// AND append here. Frontend has its own mirror in projects-cards.ts.
var KnownFactKeys = []FactKey{
FactTitleRow,
FactTypeChip,
FactStatusChip,
FactClientMatter,
FactParentPath,
FactDeadlineCounts,
FactNextEvents,
FactRecentVerlauf,
FactTeamChips,
FactReference,
FactLastActivityAt,
}
// CardDensity controls per-card padding + line-height (Kompakt / Geräumig).
// Distinct type from t-paliad-144's ListDensity (which only ranges over
// {comfortable, compact} and applies to the views.list render shape).
type CardDensity string
const (
CardDensityCompact CardDensity = "compact"
CardDensityRoomy CardDensity = "roomy"
)
// GridColumns controls the responsive grid. "auto" lets the browser
// fit-as-many-as-possible at minmax(280px, 1fr); 2/3/4 force fixed columns.
type GridColumns string
const (
GridAuto GridColumns = "auto"
GridTwo GridColumns = "2"
GridThree GridColumns = "3"
GridFour GridColumns = "4"
)
// LayoutFact is a single fact entry in the ordered facts[] array.
type LayoutFact struct {
Key FactKey `json:"key"`
Visible bool `json:"visible"`
// Count is meaningful for next-events and recent-verlauf only. nil for
// every other key. Bounded [1, 5] when set; default 3 (the seed value).
Count *int `json:"count,omitempty"`
}
// LayoutSpec is the persisted card-layout shape.
type LayoutSpec struct {
Facts []LayoutFact `json:"facts"`
Density CardDensity `json:"density"`
GridColumns GridColumns `json:"grid_columns"`
ShowAllLevels bool `json:"show_all_levels"`
}
// DefaultLayoutSpec returns the seed "Standard" layout per design §5b.4 —
// rich content set, all 9 facts visible, roomy density, auto grid.
func DefaultLayoutSpec() LayoutSpec {
three := 3
return LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: FactTypeChip, Visible: true},
{Key: FactStatusChip, Visible: true},
{Key: FactClientMatter, Visible: true},
{Key: FactParentPath, Visible: true},
{Key: FactDeadlineCounts, Visible: true},
{Key: FactNextEvents, Visible: true, Count: &three},
{Key: FactRecentVerlauf, Visible: true, Count: &three},
{Key: FactTeamChips, Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
ShowAllLevels: false,
}
}
// Validate enforces the structural invariants. Returns ErrInvalidInput
// wrapped with a precise message on the first violation.
func (s LayoutSpec) Validate() error {
if len(s.Facts) == 0 {
return fmt.Errorf("%w: layout.facts is empty", ErrInvalidInput)
}
// First visible fact must be title-row.
firstVisible := -1
for i, f := range s.Facts {
if f.Visible {
firstVisible = i
break
}
}
if firstVisible == -1 {
return fmt.Errorf("%w: layout has no visible facts", ErrInvalidInput)
}
if s.Facts[firstVisible].Key != FactTitleRow {
return fmt.Errorf("%w: first visible fact must be %q (got %q)",
ErrInvalidInput, FactTitleRow, s.Facts[firstVisible].Key)
}
seen := make(map[FactKey]bool, len(s.Facts))
for i, f := range s.Facts {
if !slices.Contains(KnownFactKeys, f.Key) {
return fmt.Errorf("%w: layout.facts[%d].key %q is not a known fact",
ErrInvalidInput, i, f.Key)
}
if seen[f.Key] {
return fmt.Errorf("%w: layout.facts has duplicate key %q",
ErrInvalidInput, f.Key)
}
seen[f.Key] = true
if f.Count != nil {
if f.Key != FactNextEvents && f.Key != FactRecentVerlauf {
return fmt.Errorf("%w: layout.facts[%d] count is only valid for next-events / recent-verlauf",
ErrInvalidInput, i)
}
if *f.Count < 1 || *f.Count > 5 {
return fmt.Errorf("%w: layout.facts[%d].count %d out of range [1, 5]",
ErrInvalidInput, i, *f.Count)
}
}
}
switch s.Density {
case CardDensityCompact, CardDensityRoomy:
default:
return fmt.Errorf("%w: layout.density %q invalid", ErrInvalidInput, s.Density)
}
switch s.GridColumns {
case GridAuto, GridTwo, GridThree, GridFour:
default:
return fmt.Errorf("%w: layout.grid_columns %q invalid", ErrInvalidInput, s.GridColumns)
}
return nil
}
// ParseLayoutSpec decodes JSON bytes and validates. Used both by the HTTP
// handler (request body) and by the service (read-back from the DB column).
func ParseLayoutSpec(b []byte) (LayoutSpec, error) {
var s LayoutSpec
if err := json.Unmarshal(b, &s); err != nil {
return LayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return LayoutSpec{}, err
}
return s, nil
}

View File

@@ -0,0 +1,155 @@
package services
// Pure-Go validator tests for LayoutSpec. No DB required.
import (
"encoding/json"
"errors"
"testing"
)
func TestDefaultLayoutSpec_IsValid(t *testing.T) {
if err := DefaultLayoutSpec().Validate(); err != nil {
t.Fatalf("DefaultLayoutSpec invalid: %v", err)
}
}
func TestLayoutSpec_RejectsEmpty(t *testing.T) {
s := LayoutSpec{
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty facts: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_RequiresTitleRowFirst(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTypeChip, Visible: true},
{Key: FactTitleRow, Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("title-row not first: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_AllowsTitleRowAfterHiddenLeading(t *testing.T) {
// type-chip is hidden so it doesn't count; title-row is the first VISIBLE.
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTypeChip, Visible: false},
{Key: FactTitleRow, Visible: true},
{Key: FactStatusChip, Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); err != nil {
t.Fatalf("title-row first-visible should be ok; got %v", err)
}
}
func TestLayoutSpec_RejectsDuplicateKey(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: FactTypeChip, Visible: true},
{Key: FactTypeChip, Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("duplicate key: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_RejectsUnknownKey(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: "made-up-fact", Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown key: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_CountBoundsAndPlacement(t *testing.T) {
bad := 7
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: FactNextEvents, Visible: true, Count: &bad},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("count out-of-range: got %v, want ErrInvalidInput", err)
}
// count on a key that doesn't accept it.
good := 3
s = LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: FactStatusChip, Visible: true, Count: &good},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("count on wrong key: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_DensityAndGrid(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{{Key: FactTitleRow, Visible: true}},
Density: "spacious", // invalid
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("invalid density: got %v, want ErrInvalidInput", err)
}
s.Density = CardDensityRoomy
s.GridColumns = "5" // invalid
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("invalid grid: got %v, want ErrInvalidInput", err)
}
}
func TestParseLayoutSpec_RoundTrip(t *testing.T) {
in := DefaultLayoutSpec()
bytes := mustJSON(t, in)
out, err := ParseLayoutSpec(bytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(out.Facts) != len(in.Facts) {
t.Errorf("facts len = %d, want %d", len(out.Facts), len(in.Facts))
}
if out.Density != in.Density || out.GridColumns != in.GridColumns {
t.Errorf("density/grid drift: out=(%s, %s) in=(%s, %s)",
out.Density, out.GridColumns, in.Density, in.GridColumns)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}

View File

@@ -1138,6 +1138,307 @@ func (s *ProjectService) ResolveClientNumber(ctx context.Context, userID, id uui
return nil, nil
}
// ============================================================================
// Cards preview (t-paliad-149 PR 2)
// ============================================================================
// CardEventPreview is one event row inside a card's "Nächste Termine" or
// "Zuletzt" section. Hoverable + clickable in the UI; route is the
// computed in-app navigation target.
type CardEventPreview struct {
Kind string `json:"kind"` // "deadline" | "appointment" | "project_event"
ID uuid.UUID `json:"id"`
Title string `json:"title"`
EventDate time.Time `json:"event_date"`
Status *string `json:"status,omitempty"` // populated for kind=deadline
ActorName *string `json:"actor_name,omitempty"` // populated for kind=project_event
Route string `json:"route"` // /projects/{pid}?focus=...
}
// ProjectCardPreview is the per-project rollup for the Cards view. One row
// per visible project; team_initials capped at 3 + team_count for the
// total. last_activity_at is the most recent event timestamp (deadline /
// appointment / project_event) across own + descendants, used by the
// orchestrator to sort cards.
type ProjectCardPreview struct {
ProjectID uuid.UUID `json:"project_id"`
NextEvents []CardEventPreview `json:"next_events"`
RecentVerlauf []CardEventPreview `json:"recent_verlauf"`
TeamInitials []string `json:"team_initials"`
TeamCount int `json:"team_count"`
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
}
// CardsPreview returns the per-project rollup for the Cards view across
// every project the user can see. The optional projectIDs slice narrows
// the rollup to a subset (used by IntersectionObserver lazy fetches).
//
// Performance: a single SQL per source (deadlines, appointments, project
// events) using ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY
// event_date) to slice top-3 each direction without N round-trips. Caller
// can wrap in a per-user TTL cache (handler does this v1).
func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, projectIDs []uuid.UUID) (map[uuid.UUID]*ProjectCardPreview, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return map[uuid.UUID]*ProjectCardPreview{}, nil
}
// Determine the visible-project set. When projectIDs is non-empty, we
// still gate every row through the visibility predicate.
out := map[uuid.UUID]*ProjectCardPreview{}
// Optional narrowing as a SQL ANY clause.
narrow := ""
args := []any{userID}
if len(projectIDs) > 0 {
idStrs := make([]string, len(projectIDs))
for i, id := range projectIDs {
idStrs[i] = id.String()
}
narrow = " AND p.id = ANY($2::uuid[])"
args = append(args, pq.StringArray(idStrs))
}
now := time.Now().UTC()
today := now.Truncate(24 * time.Hour)
// --- Source 1: upcoming Deadlines (top 3 per project, ascending). ---
type rowDeadline struct {
ProjectID uuid.UUID `db:"project_id"`
ID uuid.UUID `db:"id"`
Title string `db:"title"`
DueDate time.Time `db:"due_date"`
Status string `db:"status"`
}
var ds []rowDeadline
dq := `
WITH visible AS (
SELECT p.id FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
), ranked AS (
SELECT f.project_id, f.id, f.title, f.due_date, f.status,
ROW_NUMBER() OVER (
PARTITION BY f.project_id
ORDER BY f.due_date ASC, f.id ASC
) AS rn
FROM paliad.deadlines f
JOIN visible v ON v.id = f.project_id
WHERE f.status = 'pending' AND f.due_date >= $%d::date
)
SELECT project_id, id, title, due_date, status
FROM ranked WHERE rn <= 3
`
dq = fmt.Sprintf(dq, len(args)+1)
args = append(args, today)
if err := s.db.SelectContext(ctx, &ds, dq, args...); err != nil {
return nil, fmt.Errorf("cards preview deadlines: %w", err)
}
// --- Source 2: upcoming Appointments (top 3 per project, ascending). ---
type rowAppointment struct {
ProjectID uuid.UUID `db:"project_id"`
ID uuid.UUID `db:"id"`
Title string `db:"title"`
StartsAt time.Time `db:"starts_at"`
}
var as []rowAppointment
aq := `
WITH visible AS (
SELECT p.id FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
), ranked AS (
SELECT t.project_id, t.id, t.title, t.starts_at,
ROW_NUMBER() OVER (
PARTITION BY t.project_id
ORDER BY t.starts_at ASC, t.id ASC
) AS rn
FROM paliad.appointments t
JOIN visible v ON v.id = t.project_id
WHERE t.project_id IS NOT NULL AND t.starts_at >= $%d::timestamptz
)
SELECT project_id, id, title, starts_at
FROM ranked WHERE rn <= 3
`
// args already has [userID, projectIDs?, today]; reuse $%d for now.
aArgs := make([]any, len(args))
copy(aArgs, args)
aArgs[len(aArgs)-1] = now // last arg is the temporal bound
aq = fmt.Sprintf(aq, len(aArgs))
if err := s.db.SelectContext(ctx, &as, aq, aArgs...); err != nil {
return nil, fmt.Errorf("cards preview appointments: %w", err)
}
// --- Source 3: recent project_events (Verlauf, top 3 per project, descending). ---
type rowEvent struct {
ProjectID uuid.UUID `db:"project_id"`
ID uuid.UUID `db:"id"`
Title string `db:"title"`
CreatedAt time.Time `db:"created_at"`
ActorName *string `db:"actor_name"`
}
var es []rowEvent
eq := `
WITH visible AS (
SELECT p.id FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
), ranked AS (
SELECT pe.project_id, pe.id, pe.title, pe.created_at,
u.display_name AS actor_name,
ROW_NUMBER() OVER (
PARTITION BY pe.project_id
ORDER BY pe.created_at DESC, pe.id DESC
) AS rn
FROM paliad.project_events pe
JOIN visible v ON v.id = pe.project_id
LEFT JOIN paliad.users u ON u.id = pe.created_by
)
SELECT project_id, id, title, created_at, actor_name
FROM ranked WHERE rn <= 3
`
eArgs := []any{userID}
if len(projectIDs) > 0 {
idStrs := make([]string, len(projectIDs))
for i, id := range projectIDs {
idStrs[i] = id.String()
}
eArgs = append(eArgs, pq.StringArray(idStrs))
}
if err := s.db.SelectContext(ctx, &es, eq, eArgs...); err != nil {
return nil, fmt.Errorf("cards preview project events: %w", err)
}
// --- Source 4: team chips per project (initials + count). ---
type rowTeam struct {
ProjectID uuid.UUID `db:"project_id"`
DisplayName string `db:"display_name"`
}
var ts []rowTeam
tq := `
WITH visible AS (
SELECT p.id FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
)
SELECT pt.project_id, u.display_name
FROM paliad.project_teams pt
JOIN visible v ON v.id = pt.project_id
JOIN paliad.users u ON u.id = pt.user_id
ORDER BY pt.project_id, u.display_name
`
if err := s.db.SelectContext(ctx, &ts, tq, eArgs...); err != nil {
return nil, fmt.Errorf("cards preview teams: %w", err)
}
// Stitch into per-project structs.
get := func(pid uuid.UUID) *ProjectCardPreview {
if p, ok := out[pid]; ok {
return p
}
p := &ProjectCardPreview{
ProjectID: pid,
NextEvents: []CardEventPreview{},
RecentVerlauf: []CardEventPreview{},
TeamInitials: []string{},
}
out[pid] = p
return p
}
for _, r := range ds {
p := get(r.ProjectID)
st := r.Status
p.NextEvents = append(p.NextEvents, CardEventPreview{
Kind: "deadline",
ID: r.ID,
Title: r.Title,
EventDate: r.DueDate,
Status: &st,
Route: fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
})
bumpActivity(p, r.DueDate)
}
for _, r := range as {
p := get(r.ProjectID)
p.NextEvents = append(p.NextEvents, CardEventPreview{
Kind: "appointment",
ID: r.ID,
Title: r.Title,
EventDate: r.StartsAt,
Route: fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
})
bumpActivity(p, r.StartsAt)
}
for _, r := range es {
p := get(r.ProjectID)
p.RecentVerlauf = append(p.RecentVerlauf, CardEventPreview{
Kind: "project_event",
ID: r.ID,
Title: r.Title,
EventDate: r.CreatedAt,
ActorName: r.ActorName,
Route: fmt.Sprintf("/projects/%s?tab=verlauf&focus=%s", r.ProjectID, r.ID),
})
bumpActivity(p, r.CreatedAt)
}
for _, r := range ts {
p := get(r.ProjectID)
p.TeamCount++
if len(p.TeamInitials) < 3 {
p.TeamInitials = append(p.TeamInitials, initialsFromName(r.DisplayName))
}
}
// Sort NextEvents per project ascending, RecentVerlauf descending,
// then truncate to 3 (the SQL caps at 3 per source, but the union of
// deadline+appointment can be 6 — re-sort + cap to 3).
for _, p := range out {
sortByEventDateAsc(p.NextEvents)
if len(p.NextEvents) > 3 {
p.NextEvents = p.NextEvents[:3]
}
// RecentVerlauf is single-source already-bounded; nothing else to do.
_ = p.RecentVerlauf
}
return out, nil
}
func bumpActivity(p *ProjectCardPreview, ts time.Time) {
if p.LastActivityAt == nil || ts.After(*p.LastActivityAt) {
t := ts
p.LastActivityAt = &t
}
}
func sortByEventDateAsc(events []CardEventPreview) {
for i := 1; i < len(events); i++ {
for j := i; j > 0 && events[j].EventDate.Before(events[j-1].EventDate); j-- {
events[j], events[j-1] = events[j-1], events[j]
}
}
}
func initialsFromName(name string) string {
parts := strings.Fields(name)
if len(parts) == 0 {
return "?"
}
if len(parts) == 1 {
r := []rune(parts[0])
if len(r) == 0 {
return "?"
}
return strings.ToUpper(string(r[0]))
}
first := []rune(parts[0])
last := []rune(parts[len(parts)-1])
if len(first) == 0 || len(last) == 0 {
return strings.ToUpper(string(first) + string(last))
}
return strings.ToUpper(string(first[0]) + string(last[0]))
}
// ============================================================================
// Helpers
// ============================================================================