Files
paliad/internal/services/dashboard_service.go
m b79ef258ef feat(dashboard): Phase G — logged-in landing page
New /dashboard route serves the authenticated home screen with a
server-rendered payload (no skeleton→fetch waterfall, per design
audit §2.3). / now redirects authenticated visitors to /dashboard
and keeps the marketing landing for anonymous visitors.

- DashboardService aggregates deadline + matter summaries, the next
  7d of Fristen/Termine, and the last 10 akten_events, all scoped
  by the standard office-visibility predicate.
- Dashboard handler splices the JSON payload into dist/dashboard.html
  as window.__PALIAD_DASHBOARD__ so the client paints on first frame;
  client re-fetches /api/dashboard every 60s to stay current.
- Sidebar gains an "Übersicht" group with the Dashboard entry at the
  top; DE/EN i18n keys + traffic-light card styles added.
- Empty-state copy, onboarding hint, and 503 handling keep the page
  intact when DATABASE_URL is unset.
2026-04-16 17:27:42 +02:00

314 lines
11 KiB
Go

package services
// DashboardService aggregates the summary payload for the logged-in landing
// page: deadline counts, matter counts, upcoming Fristen/Termine, and the
// recent activity feed. Scoped to Akten the caller can see — same predicate
// as AkteService.ListVisibleForUser (see migration 006 for the canonical SQL
// version).
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/models"
)
// DashboardService reads paliad.akten/fristen/termine/akten_events to assemble
// the Dashboard payload. Office-scoped through the standard visibility rule.
type DashboardService struct {
db *sqlx.DB
users *UserService
}
// NewDashboardService wires the service to its deps.
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
return &DashboardService{db: db, users: users}
}
// DashboardData is the full payload returned to the frontend.
type DashboardData struct {
User *DashboardUser `json:"user"`
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
MatterSummary MatterSummary `json:"matter_summary"`
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []ActivityEntry `json:"recent_activity"`
}
// DashboardUser is the subset of paliad.users the dashboard header renders.
type DashboardUser struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Office string `json:"office"`
Role string `json:"role"`
}
// DeadlineSummary is the four traffic-light counts.
type DeadlineSummary struct {
Overdue int `json:"overdue" db:"overdue"`
ThisWeek int `json:"this_week" db:"this_week"`
Upcoming int `json:"upcoming" db:"upcoming"`
CompletedThisWeek int `json:"completed_this_week" db:"completed_this_week"`
}
// MatterSummary counts of visible Akten by high-level status.
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 the "Kommende Fristen" column.
type UpcomingDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
AkteID uuid.UUID `json:"akte_id" db:"akte_id"`
AkteTitle string `json:"akte_title" db:"akte_title"`
AkteRef string `json:"akte_ref" db:"akte_ref"`
Urgency string `json:"urgency"`
}
// UpcomingAppointment is one row for the "Kommende Termine" column.
// AkteID/Title/Ref are pointers because termine may be ad-hoc (no parent).
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:"termin_type"`
AkteID *uuid.UUID `json:"akte_id" db:"akte_id"`
AkteTitle *string `json:"akte_title" db:"akte_title"`
AkteRef *string `json:"akte_ref" db:"akte_ref"`
}
// 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"`
AkteID uuid.UUID `json:"akte_id" db:"akte_id"`
AkteTitle string `json:"akte_title" db:"akte_title"`
AkteRef string `json:"akte_ref" db:"akte_ref"`
Action *string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
Description *string `json:"description" db:"description"`
}
// Get builds the full dashboard payload for the given user.
//
// Returns zero-value summaries and empty lists if the user has no
// paliad.users row yet — brand-new logins still get a valid response so the
// page can render an onboarding hint instead of an error.
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{},
}
if user == nil {
return data, nil
}
data.User = &DashboardUser{
ID: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
Office: user.Office,
Role: user.Role,
}
now := time.Now()
today := now.Format("2006-01-02")
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
sevenDaysAgo := now.AddDate(0, 0, -7).UTC()
if err := s.loadSummary(ctx, data, user, today, endOfWindow, sevenDaysAgo); 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
}
annotateUrgency(data.UpcomingDeadlines, now)
return data, nil
}
// loadSummary fills DeadlineSummary and MatterSummary in one round-trip using
// CTEs that restrict to visible Akten.
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string, sevenDaysAgo time.Time) error {
query := `
WITH visible_akten AS (
SELECT id, status
FROM paliad.akten
WHERE firm_wide_visible = true
OR owning_office = $1
OR $2::uuid = ANY (collaborators)
OR $3 = 'admin'
),
deadline_stats AS (
SELECT
COUNT(*) FILTER (WHERE f.due_date < $4::date AND f.status = 'pending') AS overdue,
COUNT(*) FILTER (WHERE f.due_date >= $4::date AND f.due_date <= $5::date AND f.status = 'pending') AS this_week,
COUNT(*) FILTER (WHERE f.due_date > $5::date AND f.status = 'pending') AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed' AND f.completed_at >= $6) AS completed_this_week
FROM paliad.fristen f
JOIN visible_akten v ON v.id = f.akte_id
),
matter_stats AS (
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE status = 'archived') AS archived,
COUNT(*) AS total
FROM visible_akten
)
SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
ms.active, ms.archived, ms.total
FROM deadline_stats ds, matter_stats ms`
var row struct {
DeadlineSummary
MatterSummary
}
err := s.db.GetContext(ctx, &row, query,
user.Office, user.ID, user.Role, today, endOfWeek, sevenDaysAgo)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("dashboard summary: %w", err)
}
data.DeadlineSummary = row.DeadlineSummary
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,
a.id AS akte_id,
a.title AS akte_title,
a.aktenzeichen AS akte_ref
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
WHERE f.status = 'pending'
AND f.due_date >= $4::date
AND f.due_date <= $5::date
AND (a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin')
ORDER BY f.due_date ASC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
user.Office, user.ID, user.Role, today, endOfWeek); err != nil {
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
}
return nil
}
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
// Termine may be ad-hoc (no parent Akte). Apply visibility to Termine that
// reference an Akte; ad-hoc Termine are visible to any authenticated user.
query := `
SELECT t.id,
t.title,
t.start_at,
t.end_at,
t.termin_type,
t.akte_id,
a.title AS akte_title,
a.aktenzeichen AS akte_ref
FROM paliad.termine t
LEFT JOIN paliad.akten a ON a.id = t.akte_id
WHERE t.start_at >= $4
AND t.start_at < ($4 + interval '7 days')
AND (t.akte_id IS NULL
OR a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin')
ORDER BY t.start_at ASC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
user.Office, user.ID, user.Role, 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 {
// Timestamp preference: event_date (explicit) → created_at (fallback).
// actor_email/name come from paliad.users — NULL if the actor never
// onboarded; the UI then falls back to a "System" label.
query := `
SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
u.email AS actor_email,
u.display_name AS actor_name,
e.akte_id,
a.title AS akte_title,
a.aktenzeichen AS akte_ref,
e.event_type AS action,
e.title AS details,
e.description
FROM paliad.akten_events e
JOIN paliad.akten a ON a.id = e.akte_id
LEFT JOIN paliad.users u ON u.id = e.created_by
WHERE a.firm_wide_visible = true
OR a.owning_office = $1
OR $2::uuid = ANY (a.collaborators)
OR $3 = 'admin'
ORDER BY COALESCE(e.event_date, e.created_at) DESC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.RecentActivity, query,
user.Office, user.ID, user.Role); err != nil {
return fmt.Errorf("dashboard recent activity: %w", err)
}
return nil
}
// annotateUrgency sets the Urgency bucket on each UpcomingDeadline. Only
// status=pending deadlines with due_date ∈ [today, today+7d] are in the slice,
// but "overdue" is still emitted to be defensive across daylight-saving or
// clock skew between DB and server.
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"
}
}
}