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" } } }