From e53e1389f928f44efe43029f858ab4a46950c91f Mon Sep 17 00:00:00 2001 From: m Date: Wed, 25 Mar 2026 13:37:06 +0100 Subject: [PATCH] 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. --- backend/internal/handlers/appointments.go | 11 -- backend/internal/handlers/dashboard.go | 32 ++++ backend/internal/router/router.go | 6 + .../internal/services/dashboard_service.go | 151 ++++++++++++++++++ .../services/dashboard_service_test.go | 33 ++++ 5 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 backend/internal/handlers/dashboard.go create mode 100644 backend/internal/services/dashboard_service.go create mode 100644 backend/internal/services/dashboard_service_test.go diff --git a/backend/internal/handlers/appointments.go b/backend/internal/handlers/appointments.go index 49d8e16..16d1111 100644 --- a/backend/internal/handlers/appointments.go +++ b/backend/internal/handlers/appointments.go @@ -203,14 +203,3 @@ func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, status int, msg string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{"error": msg}) -} diff --git a/backend/internal/handlers/dashboard.go b/backend/internal/handlers/dashboard.go new file mode 100644 index 0000000..09b5dbe --- /dev/null +++ b/backend/internal/handlers/dashboard.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "net/http" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +type DashboardHandler struct { + svc *services.DashboardService +} + +func NewDashboardHandler(svc *services.DashboardService) *DashboardHandler { + return &DashboardHandler{svc: svc} +} + +func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + data, err := h.svc.Get(r.Context(), tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, data) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4b21e0d..cc2f4cb 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -27,6 +27,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { // Middleware tenantResolver := auth.NewTenantResolver(tenantSvc) + dashboardSvc := services.NewDashboardService(db) + // Handlers tenantH := handlers.NewTenantHandler(tenantSvc) caseH := handlers.NewCaseHandler(caseSvc) @@ -35,6 +37,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db) ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) + dashboardH := handlers.NewDashboardHandler(dashboardSvc) // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -86,6 +89,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update) scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete) + // Dashboard + scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) + // Placeholder routes for future phases scoped.HandleFunc("GET /api/documents", placeholder("documents")) diff --git a/backend/internal/services/dashboard_service.go b/backend/internal/services/dashboard_service.go new file mode 100644 index 0000000..a8c1b07 --- /dev/null +++ b/backend/internal/services/dashboard_service.go @@ -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 +} diff --git a/backend/internal/services/dashboard_service_test.go b/backend/internal/services/dashboard_service_test.go new file mode 100644 index 0000000..b293f76 --- /dev/null +++ b/backend/internal/services/dashboard_service_test.go @@ -0,0 +1,33 @@ +package services + +import ( + "testing" + "time" +) + +func TestDashboardDateCalculations(t *testing.T) { + // Verify the date range logic used in Get() + now := time.Date(2026, 3, 25, 14, 0, 0, 0, time.UTC) // Wednesday + + 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") + + if today != "2026-03-25" { + t.Errorf("today = %s, want 2026-03-25", today) + } + if endOfWeek != "2026-03-29" { // Sunday + t.Errorf("endOfWeek = %s, want 2026-03-29", endOfWeek) + } + if endOfNextWeek != "2026-04-05" { + t.Errorf("endOfNextWeek = %s, want 2026-04-05", endOfNextWeek) + } + if in7Days != "2026-04-01" { + t.Errorf("in7Days = %s, want 2026-04-01", in7Days) + } + if startOfMonth != "2026-03-01" { + t.Errorf("startOfMonth = %s, want 2026-03-01", startOfMonth) + } +}