Files
paliad/internal/services/dashboard_service.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

527 lines
20 KiB
Go

package services
// DashboardService aggregates the logged-in landing-page payload. Scoped to
// Projects the caller can see — same predicate as ProjectService (team-based,
// v2 data model, t-paliad-024).
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// DashboardService reads paliad.projects/deadlines/appointments/project_events for
// the Dashboard page.
type DashboardService struct {
db *sqlx.DB
users *UserService
approvals *ApprovalService
pins *PinService
}
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
return &DashboardService{db: db, users: users}
}
// SetApprovalService wires the inbox-approvals widget data source. Called
// post-construction so that DashboardService and ApprovalService can be
// stitched together at boot without a circular constructor dependency.
// Safe to leave nil — InboxSummary will then carry pending_count=0 and an
// empty entries list, and the widget renders its empty state.
func (s *DashboardService) SetApprovalService(a *ApprovalService) {
s.approvals = a
}
// SetPinService wires the pinned-projects widget data source (Slice C).
// PinService pre-dates t-paliad-219 (mig 062/063) so no new schema is
// needed. Safe to leave nil — PinnedProjects then comes back empty and
// the widget renders its empty state.
func (s *DashboardService) SetPinService(p *PinService) {
s.pins = p
}
// DashboardData is the full payload returned to the frontend.
type DashboardData struct {
User *DashboardUser `json:"user"`
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
AppointmentSummary AppointmentSummary `json:"appointment_summary"`
MatterSummary MatterSummary `json:"matter_summary"`
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []ActivityEntry `json:"recent_activity"`
InboxSummary InboxSummary `json:"inbox_summary"`
PinnedProjects []PinnedProjectRef `json:"pinned_projects"`
}
// PinnedProjectRef is one row in DashboardData.PinnedProjects — the
// minimum needed to render a clickable entry in the pinned-projects
// widget. Order matches PinService.ListPinned (pinned_at DESC).
type PinnedProjectRef struct {
ProjectID uuid.UUID `json:"project_id" db:"id"`
ProjectTitle string `json:"project_title" db:"title"`
ProjectRef string `json:"project_reference" db:"reference"`
}
// PinnedProjectsCap caps the pinned-projects preview list. The widget
// count setting tops out at 20; we fetch the cap once and let the
// client trim further per the user's setting.
const PinnedProjectsCap = 20
// InboxSummary feeds the inbox-approvals widget on the configurable
// dashboard (t-paliad-219). PendingCount is the precise number of
// approval requests that await this user's approval; Top is a small
// preview list (up to InboxTopCap entries) ordered oldest-pending-first
// so the most urgent appears first.
//
// When the ApprovalService dependency is unwired (knowledge-platform-only
// deployments, tests), PendingCount=0 and Top=[] so the widget renders
// its empty state. The data path is read-only — no writes go through
// the dashboard payload.
type InboxSummary struct {
PendingCount int `json:"pending_count"`
Top []InboxEntry `json:"top"`
}
// InboxEntry is a single row in InboxSummary.Top — the minimum needed
// to render a clickable preview ("Frist X auf Akte Y, vorgeschlagen am Z").
type InboxEntry struct {
RequestID uuid.UUID `json:"id"`
EntityType string `json:"entity_type"`
EntityTitle *string `json:"entity_title,omitempty"`
ProjectID uuid.UUID `json:"project_id"`
ProjectTitle string `json:"project_title"`
RequestedAt time.Time `json:"requested_at"`
RequesterID uuid.UUID `json:"requester_id"`
RequesterName string `json:"requester_name"`
}
// InboxTopCap caps the preview list. The widget's count setting tops out
// at 10 (see WidgetCatalog inboxCounts); we fetch the cap once and let
// the client trim further per the user's setting.
const InboxTopCap = 10
type DashboardUser struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Office string `json:"office"`
JobTitle *string `json:"job_title"`
GlobalRole string `json:"global_role"`
}
// DeadlineSummary feeds the Dashboard "Fristen auf einen Blick" cards.
//
// Bucket math is identical to DeadlineService.SummaryCounts (single source
// of truth via computeDeadlineBucketBounds) so the Dashboard and the
// /deadlines summary cards always show the same numbers (t-paliad-106 →
// t-paliad-110). The Erledigt card is gone; status=completed stays
// reachable via the dropdown filter on the EventsPage but is no longer
// rendered as a card on the dashboard rail.
type DeadlineSummary struct {
Overdue int `json:"overdue" db:"overdue"`
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
NextWeek int `json:"next_week" db:"next_week"`
Later int `json:"later" db:"later"`
}
// AppointmentSummary feeds the Dashboard "Termine auf einen Blick" rail
// (t-paliad-110). Three cards: Heute / Diese Woche / Später. No Überfällig
// (past appointments aren't urgent — they happened) and no Nächste Woche
// (low-value distinction for appointments per the design doc).
type AppointmentSummary struct {
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
Later int `json:"later" db:"later"`
}
// MatterSummary counts visible Projects by status. Field names kept as
// "matter" for JSON API compatibility with the dashboard client.
type MatterSummary struct {
Active int `json:"active" db:"active"`
Archived int `json:"archived" db:"archived"`
Total int `json:"total" db:"total"`
}
// UpcomingDeadline is one row for "Kommende Deadlines".
type UpcomingDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
ProjectTitle string `json:"project_title" db:"project_title"`
ProjectRef string `json:"project_reference" db:"project_reference"`
Urgency string `json:"urgency"`
}
// UpcomingAppointment is one row for "Kommende Appointments".
type UpcomingAppointment struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
StartAt time.Time `json:"start_at" db:"start_at"`
EndAt *time.Time `json:"end_at" db:"end_at"`
Type *string `json:"type" db:"appointment_type"`
ProjectID *uuid.UUID `json:"project_id" db:"project_id"`
ProjectTitle *string `json:"project_title" db:"project_title"`
ProjectRef *string `json:"project_reference" db:"project_reference"`
}
// ActivityEntry is one row in the "Letzte Aktivität" feed.
type ActivityEntry struct {
Timestamp time.Time `json:"timestamp" db:"timestamp"`
ActorEmail *string `json:"actor_email" db:"actor_email"`
ActorName *string `json:"actor_name" db:"actor_name"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
ProjectTitle string `json:"project_title" db:"project_title"`
ProjectRef string `json:"project_reference" db:"project_reference"`
Action *string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
Description *string `json:"description" db:"description"`
Metadata json.RawMessage `json:"metadata" db:"metadata"`
}
// Get builds the full dashboard payload.
func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*DashboardData, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
data := &DashboardData{
UpcomingDeadlines: []UpcomingDeadline{},
UpcomingAppointments: []UpcomingAppointment{},
RecentActivity: []ActivityEntry{},
PinnedProjects: []PinnedProjectRef{},
}
if user == nil {
return data, nil
}
data.User = &DashboardUser{
ID: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
Office: user.Office,
JobTitle: user.JobTitle,
GlobalRole: user.GlobalRole,
}
now := time.Now()
today := now.Format("2006-01-02")
// t-paliad-219 §18 Note B: widen the upcoming windows from 7d → 60d
// so the per-widget horizon dropdown (7/14/30/60) can filter client-
// side without re-querying. LIMIT bumps from 10 to 40 for the same
// reason — the widget's count setting tops out at 20 plus headroom
// for the agenda widget which can read from the same payload.
endOfWindow := now.AddDate(0, 0, 60).Format("2006-01-02")
bounds := computeDeadlineBucketBounds(now.UTC())
if err := s.loadSummary(ctx, data, user, bounds); err != nil {
return nil, err
}
if err := s.loadUpcomingDeadlines(ctx, data, user, today, endOfWindow); err != nil {
return nil, err
}
if err := s.loadUpcomingAppointments(ctx, data, user, now); err != nil {
return nil, err
}
if err := s.loadRecentActivity(ctx, data, user); err != nil {
return nil, err
}
if err := s.loadInboxSummary(ctx, data, user); err != nil {
return nil, err
}
if err := s.loadPinnedProjects(ctx, data, user); err != nil {
return nil, err
}
annotateUrgency(data.UpcomingDeadlines, now)
return data, nil
}
// loadPinnedProjects populates DashboardData.PinnedProjects (Slice C).
// Reads PinService.ListPinned for ordering, then materialises titles +
// references via a single visibility-filtered SELECT. When PinService
// is unwired or the user has no pins, the field comes back empty and
// the widget renders its empty state.
func (s *DashboardService) loadPinnedProjects(ctx context.Context, data *DashboardData, user *models.User) error {
if s.pins == nil {
return nil
}
ids, err := s.pins.ListPinned(ctx, user.ID)
if err != nil {
return fmt.Errorf("dashboard pinned ids: %w", err)
}
if len(ids) == 0 {
return nil
}
if len(ids) > PinnedProjectsCap {
ids = ids[:PinnedProjectsCap]
}
query := `
SELECT p.id,
p.title,
COALESCE(p.reference, '') AS reference
FROM paliad.projects p
WHERE p.id = ANY($2::uuid[])
AND ` + visibilityPredicatePositional("p", 1)
rows := []PinnedProjectRef{}
if err := s.db.SelectContext(ctx, &rows, query, user.ID, idsToArray(ids)); err != nil {
return fmt.Errorf("dashboard pinned projects: %w", err)
}
// Restore pinned-at order: PinService.ListPinned ordered DESC; the
// SELECT above doesn't preserve that. Build a position map and
// re-sort.
pos := make(map[uuid.UUID]int, len(ids))
for i, id := range ids {
pos[id] = i
}
out := make([]PinnedProjectRef, 0, len(rows))
for _, r := range rows {
out = append(out, r)
}
// Tiny n (cap=20); a simple insertion-style swap is enough.
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if pos[out[j].ProjectID] < pos[out[i].ProjectID] {
out[i], out[j] = out[j], out[i]
}
}
}
data.PinnedProjects = out
return nil
}
// idsToArray flips a []uuid.UUID into a Postgres uuid[] payload via
// pq.Array — mirrors the pattern in rule_editor_orphans.go.
func idsToArray(ids []uuid.UUID) interface{} {
out := make([]string, len(ids))
for i, id := range ids {
out[i] = id.String()
}
return pq.Array(out)
}
// loadSummary fills DeadlineSummary + AppointmentSummary + MatterSummary.
//
// Bucket math comes from computeDeadlineBucketBounds (deadline_service.go) so
// the buckets stay in lockstep with /api/deadlines/summary and the unified
// /api/events/summary used by the EventsPage (t-paliad-110).
//
// Bucket model on the Dashboard:
// - Fristen rail: Überfällig (conditional alarm) · Heute · Diese Woche ·
// Nächste Woche · Später. Erledigt is no longer rendered as a card —
// status=completed stays reachable via the EventsPage filter dropdown.
// - Termine rail: Heute · Diese Woche · Später. No Überfällig, no
// Nächste Woche (low-value distinction for appointments).
//
// Visibility predicate: see internal/services/visibility.go — global_admin
// shortcut OR any ancestor-or-direct team membership. Applied once via a CTE;
// downstream queries reuse the same helper.
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, bounds deadlineBucketBounds) error {
query := `
WITH visible_projekte AS (
SELECT p.id, p.status
FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1) + `
),
deadline_stats AS (
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < $2::date) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date = $2::date) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= $3::date AND f.due_date < $4::date) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= $4::date AND f.due_date < $5::date) AS next_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= $5::date) AS later
FROM paliad.deadlines f
JOIN visible_projekte v ON v.id = f.project_id
),
appointment_stats AS (
SELECT
COUNT(*) FILTER (WHERE t.start_at >= $2 AND t.start_at < $3) AS today,
COUNT(*) FILTER (WHERE t.start_at >= $3 AND t.start_at < $4) AS this_week,
COUNT(*) FILTER (WHERE t.start_at >= $5) AS later
FROM paliad.appointments t
LEFT JOIN visible_projekte v ON v.id = t.project_id
WHERE (t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND v.id IS NOT NULL)
),
matter_stats AS (
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE status = 'archived') AS archived,
COUNT(*) AS total
FROM visible_projekte
)
SELECT ds.overdue, ds.today, ds.this_week, ds.next_week, ds.later,
aps.today AS appt_today, aps.this_week AS appt_this_week, aps.later AS appt_later,
ms.active, ms.archived, ms.total
FROM deadline_stats ds, appointment_stats aps, matter_stats ms`
var row struct {
DeadlineSummary
ApptToday int `db:"appt_today"`
ApptThisWeek int `db:"appt_this_week"`
ApptLater int `db:"appt_later"`
MatterSummary
}
err := s.db.GetContext(ctx, &row, query,
user.ID, bounds.today, bounds.tomorrow, bounds.nextMonday, bounds.weekAfter)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("dashboard summary: %w", err)
}
data.DeadlineSummary = row.DeadlineSummary
data.AppointmentSummary = AppointmentSummary{
Today: row.ApptToday,
ThisWeek: row.ApptThisWeek,
Later: row.ApptLater,
}
data.MatterSummary = row.MatterSummary
return nil
}
func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string) error {
query := `
SELECT f.id,
f.title,
to_char(f.due_date, 'YYYY-MM-DD') AS due_date,
p.id AS project_id,
p.title AS project_title,
COALESCE(p.reference, '') AS project_reference
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE f.status = 'pending'
AND f.due_date >= $2::date
AND f.due_date <= $3::date
AND ` + visibilityPredicatePositional("p", 1) + `
ORDER BY f.due_date ASC
LIMIT 40`
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
user.ID, today, endOfWeek); err != nil {
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
}
return nil
}
// loadInboxSummary populates DashboardData.InboxSummary — the open-
// approval count + top InboxTopCap entries for the inbox-approvals
// widget (t-paliad-219). When ApprovalService is unwired (knowledge-
// platform-only deployments, tests), the function is a no-op and the
// widget renders its empty state.
func (s *DashboardService) loadInboxSummary(ctx context.Context, data *DashboardData, user *models.User) error {
data.InboxSummary = InboxSummary{Top: []InboxEntry{}}
if s.approvals == nil {
return nil
}
cnt, err := s.approvals.PendingCountForUser(ctx, user.ID)
if err != nil {
return fmt.Errorf("dashboard inbox count: %w", err)
}
data.InboxSummary.PendingCount = cnt
if cnt == 0 {
return nil
}
rows, err := s.approvals.ListPendingForApprover(ctx, user.ID, InboxFilter{Limit: InboxTopCap})
if err != nil {
return fmt.Errorf("dashboard inbox top: %w", err)
}
top := make([]InboxEntry, 0, len(rows))
for _, r := range rows {
top = append(top, InboxEntry{
RequestID: r.ID,
EntityType: r.EntityType,
EntityTitle: r.EntityTitle,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
RequestedAt: r.RequestedAt,
RequesterID: r.RequestedBy,
RequesterName: r.RequesterName,
})
}
data.InboxSummary.Top = top
return nil
}
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
query := `
SELECT t.id,
t.title,
t.start_at,
t.end_at,
t.appointment_type,
t.project_id,
p.title AS project_title,
COALESCE(p.reference, NULL) AS project_reference
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE t.start_at >= $2
AND t.start_at < ($2 + interval '60 days')
AND (
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
)
ORDER BY t.start_at ASC
LIMIT 40`
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
user.ID, now); err != nil {
return fmt.Errorf("dashboard upcoming appointments: %w", err)
}
return nil
}
func (s *DashboardService) loadRecentActivity(ctx context.Context, data *DashboardData, user *models.User) error {
query := `
SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
u.email AS actor_email,
u.display_name AS actor_name,
e.project_id,
p.title AS project_title,
COALESCE(p.reference, '') AS project_reference,
e.event_type AS action,
e.title AS details,
e.description,
e.metadata AS metadata
FROM paliad.project_events e
JOIN paliad.projects p ON p.id = e.project_id
LEFT JOIN paliad.users u ON u.id = e.created_by
WHERE ` + visibilityPredicatePositional("p", 1) + `
ORDER BY COALESCE(e.event_date, e.created_at) DESC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.RecentActivity, query, user.ID); err != nil {
return fmt.Errorf("dashboard recent activity: %w", err)
}
return nil
}
func annotateUrgency(deadlines []UpcomingDeadline, now time.Time) {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := range deadlines {
due, err := time.ParseInLocation("2006-01-02", deadlines[i].DueDate, now.Location())
if err != nil {
deadlines[i].Urgency = "soon"
continue
}
days := int(due.Sub(today).Hours() / 24)
switch {
case days < 0:
deadlines[i].Urgency = "overdue"
case days == 0:
deadlines[i].Urgency = "today"
case days <= 2:
deadlines[i].Urgency = "urgent"
default:
deadlines[i].Urgency = "soon"
}
}
}