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
140 lines
4.9 KiB
Go
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)
|
|
}
|