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
107 lines
3.9 KiB
Go
107 lines
3.9 KiB
Go
package services
|
|
|
|
// FirmDashboardDefaultService manages paliad.firm_dashboard_default — the
|
|
// optional firm-wide dashboard layout that DashboardLayoutService prefers
|
|
// over the code-resident FactoryDefaultLayout when seeding a new user's
|
|
// row or resetting an existing layout.
|
|
//
|
|
// Design: docs/design-dashboard-configurable-2026-05-20.md §8.2
|
|
// (firm-wide default, deferred to v1.1 — activated in Slice C).
|
|
//
|
|
// Single optional row identified by id=1. Get returns (spec, true, nil)
|
|
// when set, (zero, false, nil) when never set. Set overwrites; Clear
|
|
// deletes (so GetOrSeed reverts to FactoryDefaultLayout).
|
|
//
|
|
// The HTTP layer (handlers/firm_dashboard_default.go) enforces admin-only
|
|
// via auth.RequireAdmin. The service itself takes no admin parameter —
|
|
// it trusts its callers because the only writer is the admin handler;
|
|
// the read path is used by DashboardLayoutService on every seed/reset.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
// FirmDashboardDefaultService manages paliad.firm_dashboard_default.
|
|
type FirmDashboardDefaultService struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// NewFirmDashboardDefaultService wires the service.
|
|
func NewFirmDashboardDefaultService(db *sqlx.DB) *FirmDashboardDefaultService {
|
|
return &FirmDashboardDefaultService{db: db}
|
|
}
|
|
|
|
// Get returns (spec, true, nil) if a firm default is set, (zero, false,
|
|
// nil) otherwise. SanitizeForRead is applied so callers always receive a
|
|
// version-coherent spec; if anything had to be dropped (e.g. an admin
|
|
// stashed a layout that references widgets we later removed), the
|
|
// cleanup is in-memory only and the next admin write will persist it.
|
|
func (s *FirmDashboardDefaultService) Get(ctx context.Context) (DashboardLayoutSpec, bool, error) {
|
|
var raw json.RawMessage
|
|
err := s.db.GetContext(ctx, &raw, `
|
|
SELECT layout_json
|
|
FROM paliad.firm_dashboard_default
|
|
WHERE id = 1
|
|
`)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return DashboardLayoutSpec{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, false, fmt.Errorf("get firm dashboard default: %w", err)
|
|
}
|
|
var spec DashboardLayoutSpec
|
|
if err := json.Unmarshal(raw, &spec); err != nil {
|
|
// Stored row is unparseable — treat as missing so the seed path
|
|
// reverts to FactoryDefaultLayout rather than crash.
|
|
return DashboardLayoutSpec{}, false, nil
|
|
}
|
|
spec.SanitizeForRead()
|
|
return spec, true, nil
|
|
}
|
|
|
|
// Set persists the layout as the firm-wide default. Validates against the
|
|
// catalog so an admin can't seed a layout that violates the contract.
|
|
// updatedBy is recorded for audit; passing uuid.Nil clears the column.
|
|
func (s *FirmDashboardDefaultService) Set(ctx context.Context, spec DashboardLayoutSpec, updatedBy uuid.UUID) (DashboardLayoutSpec, error) {
|
|
if err := spec.Validate(); err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
bytes, err := json.Marshal(spec)
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, fmt.Errorf("firm dashboard default marshal: %w", err)
|
|
}
|
|
var updaterArg interface{}
|
|
if updatedBy != uuid.Nil {
|
|
updaterArg = updatedBy
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO paliad.firm_dashboard_default (id, layout_json, updated_by, updated_at)
|
|
VALUES (1, $1, $2, now())
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET layout_json = EXCLUDED.layout_json,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = now()
|
|
`, json.RawMessage(bytes), updaterArg)
|
|
if err != nil {
|
|
return DashboardLayoutSpec{}, fmt.Errorf("firm dashboard default upsert: %w", err)
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
// Clear deletes the firm default so seeds/resets revert to FactoryDefault-
|
|
// Layout. Idempotent — clearing an already-absent row is a no-op.
|
|
func (s *FirmDashboardDefaultService) Clear(ctx context.Context) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM paliad.firm_dashboard_default WHERE id = 1`)
|
|
if err != nil {
|
|
return fmt.Errorf("firm dashboard default clear: %w", err)
|
|
}
|
|
return nil
|
|
}
|