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.
314 lines
11 KiB
Go
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"
|
|
}
|
|
}
|
|
}
|