Files
paliad/internal/handlers/firm_dashboard_default.go
mAi 6b565be830 feat(dashboard): t-paliad-219 Slice C — catalog expansion + firm-wide admin default
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
2026-05-20 19:15:32 +02:00

140 lines
4.9 KiB
Go

package handlers
// HTTP handlers for the firm-wide dashboard default layout (t-paliad-219
// Slice C). All four endpoints sit behind the adminGate so only
// global_admin can read or mutate. The per-user GetOrSeed/ResetToDefault
// path consumes the firm default via DashboardLayoutService — the read
// surface here is just the admin's read-back of the current row.
//
// GET /api/admin/firm-dashboard-default — current row, or 204
// PUT /api/admin/firm-dashboard-default — replace
// DELETE /api/admin/firm-dashboard-default — clear (revert to factory)
// POST /api/me/dashboard-layout/promote — promote caller's own
// current layout to firm
// default. Admin convenience.
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/admin/firm-dashboard-default — returns the current firm-wide
// default layout, or 204 when none is set. Admins read this on the
// firm-default admin surface to verify the active layout.
func handleGetFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
spec, ok, err := dbSvc.firmDashboardDefault.Get(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
if !ok {
// Empty firm default — the caller can fall back to the factory
// shape via GET /api/dashboard-widget-catalog + FactoryDefault-
// Layout logic mirrored client-side. 204 is cheaper than
// shipping an "is_set: false" wrapper.
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, spec)
}
// PUT /api/admin/firm-dashboard-default — replace the firm-wide default.
// Body must be a complete DashboardLayoutSpec. The admin is recorded as
// updated_by for audit.
func handlePutFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
var spec services.DashboardLayoutSpec
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// DELETE /api/admin/firm-dashboard-default — clear the firm default so
// future seeds/resets revert to the code-resident FactoryDefaultLayout.
// Idempotent.
func handleDeleteFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
if err := dbSvc.firmDashboardDefault.Clear(r.Context()); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/me/dashboard-layout/promote — admin convenience. Takes the
// caller's current layout (whatever's in user_dashboard_layouts for
// them) and promotes it to the firm-wide default. Saves an admin the
// step of crafting a layout in a JSON editor — they edit their own
// dashboard via the standard /dashboard editor, then promote one click.
//
// Admin-only at the route level (handlers.go wires this under adminGate).
// The handler itself does not re-check admin — that's the gate's job.
func handlePromoteDashboardLayoutToFirmDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil || dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
// Read the admin's own current layout (seeding the factory if they
// somehow lack a row — vanishingly unlikely for an admin who's
// logging in to promote, but the safety belt costs nothing).
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}