feat: add dashboard aggregation endpoint (Phase 2I)
GET /api/dashboard returns aggregated data: - deadline_summary: overdue, due this/next week, ok counts - case_summary: active, new this month, closed counts - upcoming_deadlines: next 7 days with case info - upcoming_appointments: next 7 days - recent_activity: last 10 case events Uses efficient CTE query for summaries. Also fixes duplicate writeJSON/writeError declarations in appointments handler.
This commit is contained in:
151
backend/internal/services/dashboard_service.go
Normal file
151
backend/internal/services/dashboard_service.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type DashboardService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewDashboardService(db *sqlx.DB) *DashboardService {
|
||||
return &DashboardService{db: db}
|
||||
}
|
||||
|
||||
type DashboardData struct {
|
||||
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
|
||||
CaseSummary CaseSummary `json:"case_summary"`
|
||||
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
||||
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
||||
RecentActivity []RecentActivity `json:"recent_activity"`
|
||||
}
|
||||
|
||||
type DeadlineSummary struct {
|
||||
OverdueCount int `json:"overdue_count" db:"overdue_count"`
|
||||
DueThisWeek int `json:"due_this_week" db:"due_this_week"`
|
||||
DueNextWeek int `json:"due_next_week" db:"due_next_week"`
|
||||
OKCount int `json:"ok_count" db:"ok_count"`
|
||||
}
|
||||
|
||||
type CaseSummary struct {
|
||||
ActiveCount int `json:"active_count" db:"active_count"`
|
||||
NewThisMonth int `json:"new_this_month" db:"new_this_month"`
|
||||
ClosedCount int `json:"closed_count" db:"closed_count"`
|
||||
}
|
||||
|
||||
type UpcomingDeadline struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
DueDate string `json:"due_date" db:"due_date"`
|
||||
CaseNumber string `json:"case_number" db:"case_number"`
|
||||
CaseTitle string `json:"case_title" db:"case_title"`
|
||||
Status string `json:"status" db:"status"`
|
||||
}
|
||||
|
||||
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"`
|
||||
CaseNumber *string `json:"case_number" db:"case_number"`
|
||||
Location *string `json:"location" db:"location"`
|
||||
}
|
||||
|
||||
type RecentActivity struct {
|
||||
EventType *string `json:"event_type" db:"event_type"`
|
||||
Title string `json:"title" db:"title"`
|
||||
CaseNumber string `json:"case_number" db:"case_number"`
|
||||
EventDate *time.Time `json:"event_date" db:"event_date"`
|
||||
}
|
||||
|
||||
func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*DashboardData, error) {
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
|
||||
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
|
||||
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||
|
||||
data := &DashboardData{}
|
||||
|
||||
// Single query with CTEs for deadline + case summaries
|
||||
summaryQuery := `
|
||||
WITH deadline_stats AS (
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE due_date < $2 AND status = 'pending') AS overdue_count,
|
||||
COUNT(*) FILTER (WHERE due_date >= $2 AND due_date <= $3 AND status = 'pending') AS due_this_week,
|
||||
COUNT(*) FILTER (WHERE due_date > $3 AND due_date <= $4 AND status = 'pending') AS due_next_week,
|
||||
COUNT(*) FILTER (WHERE due_date > $4 AND status = 'pending') AS ok_count
|
||||
FROM deadlines
|
||||
WHERE tenant_id = $1
|
||||
),
|
||||
case_stats AS (
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
|
||||
COUNT(*) FILTER (WHERE created_at >= $5::date AND status != 'archived') AS new_this_month,
|
||||
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed_count
|
||||
FROM cases
|
||||
WHERE tenant_id = $1
|
||||
)
|
||||
SELECT
|
||||
ds.overdue_count, ds.due_this_week, ds.due_next_week, ds.ok_count,
|
||||
cs.active_count, cs.new_this_month, cs.closed_count
|
||||
FROM deadline_stats ds, case_stats cs`
|
||||
|
||||
var summaryRow struct {
|
||||
DeadlineSummary
|
||||
CaseSummary
|
||||
}
|
||||
err := s.db.GetContext(ctx, &summaryRow, summaryQuery, tenantID, today, endOfWeek, endOfNextWeek, startOfMonth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dashboard summary: %w", err)
|
||||
}
|
||||
data.DeadlineSummary = summaryRow.DeadlineSummary
|
||||
data.CaseSummary = summaryRow.CaseSummary
|
||||
|
||||
// Upcoming deadlines (next 7 days)
|
||||
deadlineQuery := `
|
||||
SELECT d.id, d.title, d.due_date, c.case_number, c.title AS case_title, d.status
|
||||
FROM deadlines d
|
||||
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
|
||||
WHERE d.tenant_id = $1 AND d.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3
|
||||
ORDER BY d.due_date ASC`
|
||||
|
||||
data.UpcomingDeadlines = []UpcomingDeadline{}
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, deadlineQuery, tenantID, today, in7Days); err != nil {
|
||||
return nil, fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||
}
|
||||
|
||||
// Upcoming appointments (next 7 days)
|
||||
appointmentQuery := `
|
||||
SELECT a.id, a.title, a.start_at, c.case_number, a.location
|
||||
FROM appointments a
|
||||
LEFT JOIN cases c ON c.id = a.case_id AND c.tenant_id = a.tenant_id
|
||||
WHERE a.tenant_id = $1 AND a.start_at >= $2::timestamp AND a.start_at < ($2::date + interval '7 days')
|
||||
ORDER BY a.start_at ASC`
|
||||
|
||||
data.UpcomingAppointments = []UpcomingAppointment{}
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, appointmentQuery, tenantID, now); err != nil {
|
||||
return nil, fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||
}
|
||||
|
||||
// Recent activity (last 10 case events)
|
||||
activityQuery := `
|
||||
SELECT ce.event_type, ce.title, c.case_number, ce.event_date
|
||||
FROM case_events ce
|
||||
JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id
|
||||
WHERE ce.tenant_id = $1
|
||||
ORDER BY COALESCE(ce.event_date, ce.created_at) DESC
|
||||
LIMIT 10`
|
||||
|
||||
data.RecentActivity = []RecentActivity{}
|
||||
if err := s.db.SelectContext(ctx, &data.RecentActivity, activityQuery, tenantID); err != nil {
|
||||
return nil, fmt.Errorf("dashboard recent activity: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
Reference in New Issue
Block a user