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
94 lines
2.5 KiB
Go
94 lines
2.5 KiB
Go
package services
|
|
|
|
// Live-DB tests for FirmDashboardDefaultService — gated on
|
|
// TEST_DATABASE_URL like the rest of the integration suite.
|
|
//
|
|
// These cover the round-trip (Set → Get → Clear → Get) and the
|
|
// SanitizeForRead behavior on read. Pure-function validation lives in
|
|
// dashboard_layout_spec_test.go.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
func openTestDBForFirmDefault(t *testing.T) *sqlx.DB {
|
|
t.Helper()
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping firm-dashboard-default live test")
|
|
}
|
|
db, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func TestFirmDashboardDefault_RoundTrip(t *testing.T) {
|
|
db := openTestDBForFirmDefault(t)
|
|
defer db.Close()
|
|
svc := NewFirmDashboardDefaultService(db)
|
|
ctx := context.Background()
|
|
|
|
// Start clean — prior tests may have left a row.
|
|
if err := svc.Clear(ctx); err != nil {
|
|
t.Fatalf("pre-clear: %v", err)
|
|
}
|
|
|
|
if _, ok, err := svc.Get(ctx); err != nil || ok {
|
|
t.Fatalf("Get after Clear: ok=%v err=%v; want ok=false err=nil", ok, err)
|
|
}
|
|
|
|
spec := FactoryDefaultLayout()
|
|
if _, err := svc.Set(ctx, spec, uuid.Nil); err != nil {
|
|
t.Fatalf("Set factory: %v", err)
|
|
}
|
|
|
|
got, ok, err := svc.Get(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("Get: ok=false after Set; want true")
|
|
}
|
|
if len(got.Widgets) != len(spec.Widgets) {
|
|
t.Errorf("widget count mismatch: %d vs %d", len(got.Widgets), len(spec.Widgets))
|
|
}
|
|
if got.Version != spec.Version {
|
|
t.Errorf("version mismatch: %d vs %d", got.Version, spec.Version)
|
|
}
|
|
|
|
// Clear is idempotent.
|
|
if err := svc.Clear(ctx); err != nil {
|
|
t.Fatalf("clear after set: %v", err)
|
|
}
|
|
if err := svc.Clear(ctx); err != nil {
|
|
t.Fatalf("second clear: %v", err)
|
|
}
|
|
if _, ok, err := svc.Get(ctx); err != nil || ok {
|
|
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestFirmDashboardDefault_RejectsInvalid(t *testing.T) {
|
|
db := openTestDBForFirmDefault(t)
|
|
defer db.Close()
|
|
svc := NewFirmDashboardDefaultService(db)
|
|
ctx := context.Background()
|
|
|
|
bad := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
|
|
}}
|
|
_, err := svc.Set(ctx, bad, uuid.Nil)
|
|
if err == nil {
|
|
t.Fatal("Set with invalid count: err=nil; want ErrInvalidInput")
|
|
}
|
|
}
|