Three additions on top of Slice B's edit-mode chrome. **Catalog expansion (2 new widgets, default-hidden — opt-in via picker):** - pinned-projects: surfaces a list of the user's pinned matters via the pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New DashboardService.loadPinnedProjects joins paliad.user_pinned_projects to paliad.projects under the standard visibility predicate, preserves pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects []PinnedProjectRef grows DashboardData; SetPinService wired post-construction to mirror the SetApprovalService pattern. - quick-actions: pure UI affordance with three buttons linking to the existing /projects/new, /deadlines/new, /appointments/new routes. No backend payload, no settings schema. Both default-hidden — m's brief asked for "high-value adds"; injecting new widgets into every user's dashboard unannounced would be loud. Factory test relaxed: visibility now matches catalog.DefaultVisible instead of the previous "all-visible" invariant. **Firm-wide admin default (mig 117 + new service + 4 endpoints):** - paliad.firm_dashboard_default: single-row table (id smallint PK CHECK id=1) with layout_json + updated_by + updated_at. RLS: SELECT authenticated, no INSERT/UPDATE policy (writes go through the service-role connection behind the adminGate). - FirmDashboardDefaultService Get/Set/Clear. Validates against the catalog on Set so an admin can't seed an invalid layout. - DashboardLayoutService.SetFirmDefaultService wires in the firm source. Both GetOrSeed and ResetToDefault now prefer the firm default over the code-resident FactoryDefaultLayout when one is set. Nil-safe — empty firm row falls back to the factory layout, transient DB errors fall back too (a blip can't strand a user without a dashboard). - HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin- gated). POST /api/me/dashboard-layout/promote: admin convenience — reads the admin's own current layout and stashes it as the firm default (saves the JSON-editor step; admins edit via /dashboard's normal editor, then click Promote). **Frontend (Slice B's edit-mode footer grew an admin button):** - "Als Firmen-Standard speichern" button in the edit footer; hidden via CSS-inline until syncPromoteButtonVisibility unhides for global_admin. Confirm() → POST /promote → toast. - The existing "Auf Standard zurücksetzen" copy stays the same — the semantics now "firm default if set, else factory", which is the desired surface: users see one canonical "Standard" link. i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*, dashboard.edit.promote*). i18n-keys.ts regenerated by build. m/paliad#46. go build ./... clean; go vet ./... clean go test ./internal/... clean (Slice C catalog test + factory-default test relaxation; FirmDashboardDefault round-trip tests gated on TEST_DATABASE_URL) Migration 117 dry-run: PASS (other dry-run failures are pre-existing local-DB collisions on origin/main; mig 117 itself clean) bun run build clean: dashboard.html carries new section markup + admin button; dashboard.js bundles renderPinnedProjects + promote handler + all new i18n keys
190 lines
6.9 KiB
Go
190 lines
6.9 KiB
Go
package services
|
|
|
|
// DashboardLayoutService is the CRUD layer for paliad.user_dashboard_layouts —
|
|
// per-user configurable dashboard layout for /dashboard.
|
|
//
|
|
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.4.
|
|
//
|
|
// Visibility: every read and write is scoped to the calling user via the
|
|
// RLS policy `user_dashboard_layouts_owner_all` on auth.uid() = user_id.
|
|
// The service also AND-joins user_id in SQL for defense-in-depth.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
// DashboardLayoutService manages paliad.user_dashboard_layouts.
|
|
type DashboardLayoutService struct {
|
|
db *sqlx.DB
|
|
firmDefault *FirmDashboardDefaultService
|
|
}
|
|
|
|
// NewDashboardLayoutService wires the service.
|
|
func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService {
|
|
return &DashboardLayoutService{db: db}
|
|
}
|
|
|
|
// SetFirmDefaultService wires the firm-wide default source (Slice C).
|
|
// When set and non-empty, GetOrSeed/ResetToDefault prefer it over the
|
|
// code-resident FactoryDefaultLayout. nil-safe — when unwired or when
|
|
// the table is empty, behavior falls back to the code-resident default.
|
|
func (s *DashboardLayoutService) SetFirmDefaultService(f *FirmDashboardDefaultService) {
|
|
s.firmDefault = f
|
|
}
|
|
|
|
// defaultLayout returns the firm-wide default if one is set, else the
|
|
// code-resident FactoryDefaultLayout. Used by the seed and reset paths.
|
|
// On any error reading the firm row, falls back to the factory default
|
|
// so a transient DB blip can't strand a user without a dashboard.
|
|
func (s *DashboardLayoutService) defaultLayout(ctx context.Context) DashboardLayoutSpec {
|
|
if s.firmDefault == nil {
|
|
return FactoryDefaultLayout()
|
|
}
|
|
spec, ok, err := s.firmDefault.Get(ctx)
|
|
if err != nil || !ok {
|
|
return FactoryDefaultLayout()
|
|
}
|
|
return spec
|
|
}
|
|
|
|
// GetOrSeed returns the caller's saved layout. On first call for a user
|
|
// (no row), it inserts and returns the factory default. The seed is
|
|
// idempotent — concurrent first-loads converge to the same row via the
|
|
// ON CONFLICT DO NOTHING clause.
|
|
//
|
|
// The returned spec has SanitizeForRead applied; if any entries were
|
|
// dropped (catalog shrank) the cleaned spec is also persisted back so the
|
|
// next write doesn't trip on stale entries.
|
|
func (s *DashboardLayoutService) GetOrSeed(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
|
spec, found, err := s.fetch(ctx, userID)
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
if !found {
|
|
return s.seedFactoryDefault(ctx, userID)
|
|
}
|
|
if spec.SanitizeForRead() {
|
|
// Best-effort cleanup; on failure we still return the in-memory
|
|
// sanitized spec — the user sees a clean dashboard either way.
|
|
_ = s.upsert(ctx, userID, spec)
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
// Update validates the spec and UPSERTs it. Returns the persisted spec
|
|
// (round-tripped through the DB to confirm storage).
|
|
func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) (DashboardLayoutSpec, error) {
|
|
if err := spec.Validate(); err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
if err := s.upsert(ctx, userID, spec); err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
out, found, err := s.fetch(ctx, userID)
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
if !found {
|
|
return DashboardLayoutSpec{}, fmt.Errorf("dashboard layout vanished after upsert for user %s", userID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ResetToDefault overwrites the user's layout with the firm-wide default
|
|
// when one is set, otherwise with the code-resident factory default
|
|
// (Slice C). The single user-facing "Auf Standard zurücksetzen" link
|
|
// always lands the user on whatever the firm considers default at the
|
|
// time of the click — admins can update it later and users get the new
|
|
// default on their next reset.
|
|
func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
|
def := s.defaultLayout(ctx)
|
|
if err := s.upsert(ctx, userID, def); err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
return def, nil
|
|
}
|
|
|
|
// fetch returns (spec, found, err). found=false means the user has no row
|
|
// yet — the seed path takes over.
|
|
func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, bool, error) {
|
|
var raw json.RawMessage
|
|
err := s.db.GetContext(ctx, &raw, `
|
|
SELECT layout_json
|
|
FROM paliad.user_dashboard_layouts
|
|
WHERE user_id = $1
|
|
`, userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return DashboardLayoutSpec{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, false, fmt.Errorf("fetch dashboard layout: %w", err)
|
|
}
|
|
var spec DashboardLayoutSpec
|
|
if err := json.Unmarshal(raw, &spec); err != nil {
|
|
// Stored row is unparseable — treat as a missing row, the seed
|
|
// path will overwrite it. Log via the returned error wrapper.
|
|
return DashboardLayoutSpec{}, false, fmt.Errorf("dashboard layout JSON decode for user %s: %w", userID, err)
|
|
}
|
|
return spec, true, nil
|
|
}
|
|
|
|
// seedFactoryDefault inserts the firm-wide default (if set) or the
|
|
// code-resident factory layout for a brand-new user. ON CONFLICT DO
|
|
// NOTHING handles the race where two concurrent first loads both miss
|
|
// the SELECT and both try to insert. Function name kept as "Factory"
|
|
// even though it may now seed the firm default — renaming the call site
|
|
// would churn one file for no callsite benefit.
|
|
func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
|
def := s.defaultLayout(ctx)
|
|
bytes, err := json.Marshal(def)
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err)
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (user_id) DO NOTHING
|
|
`, userID, json.RawMessage(bytes)); err != nil {
|
|
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout insert: %w", err)
|
|
}
|
|
// Re-fetch in case ON CONFLICT DO NOTHING let another writer's row win;
|
|
// either way the user now has a row.
|
|
out, found, err := s.fetch(ctx, userID)
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
if !found {
|
|
// Extremely unlikely — would mean the row vanished between
|
|
// INSERT and SELECT. Return the factory default in-memory.
|
|
return def, nil
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// upsert overwrites the layout. updated_at gets bumped on conflict so
|
|
// callers can observe write recency.
|
|
func (s *DashboardLayoutService) upsert(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) error {
|
|
bytes, err := json.Marshal(spec)
|
|
if err != nil {
|
|
return fmt.Errorf("dashboard layout marshal: %w", err)
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (user_id) DO UPDATE
|
|
SET layout_json = EXCLUDED.layout_json,
|
|
updated_at = now()
|
|
`, userID, json.RawMessage(bytes))
|
|
if err != nil {
|
|
return fmt.Errorf("dashboard layout upsert: %w", err)
|
|
}
|
|
return nil
|
|
}
|