// Dashboard aggregation store. MVP: a single struct of live COUNT/GROUP BY // queries per tenant, no caching layer and no materialized views. All queries // are tenant-scoped (WHERE tenant_id = $1) exactly like the rest of the store. package storage import ( "context" "fmt" ) // DashboardStats is the aggregated key-figure snapshot for one tenant, served // by GET /api/dashboard. type DashboardStats struct { TotalDocuments int64 `json:"total_documents"` DocumentsThisMonth int64 `json:"documents_this_month"` TrashCount int64 `json:"trash_count"` PendingDeleteRequests int64 `json:"pending_delete_requests"` RemindersDue int64 `json:"reminders_due"` RemindersUpcoming7d int64 `json:"reminders_upcoming_7d"` RetentionExpiring30d int64 `json:"retention_expiring_30d"` DocumentsByType []DocumentTypeCount `json:"documents_by_type"` } // DocumentTypeCount is one entry of the documents_by_type breakdown. type DocumentTypeCount struct { DocumentTypeName string `json:"document_type_name"` Count int64 `json:"count"` } // GetDashboardStats computes the aggregated dashboard key figures for a tenant. // Reminders are additionally scoped to the requesting user (reminders are // per-user like in ListReminders); the document/trash/retention figures are // tenant-wide. func (s *Store) GetDashboardStats(ctx context.Context, tenantID, userID int64) (*DashboardStats, error) { var stats DashboardStats // Documents: active count + this-calendar-month count in one scan. err := s.db.QueryRow(ctx, ` SELECT COUNT(*) FILTER (WHERE deleted_at IS NULL), COUNT(*) FILTER (WHERE deleted_at IS NULL AND created_at >= date_trunc('month', now())), COUNT(*) FILTER (WHERE deleted_at IS NOT NULL) FROM documents WHERE tenant_id = $1 `, tenantID).Scan(&stats.TotalDocuments, &stats.DocumentsThisMonth, &stats.TrashCount) if err != nil { return nil, fmt.Errorf("storage: dashboard document counts: %w", err) } // Open delete requests (awaiting confirmation or blocked by retention). err = s.db.QueryRow(ctx, ` SELECT COUNT(*) FROM document_delete_requests WHERE tenant_id = $1 AND status IN ('pending', 'blocked_retention') `, tenantID).Scan(&stats.PendingDeleteRequests) if err != nil { return nil, fmt.Errorf("storage: dashboard pending delete requests: %w", err) } // Reminders (per user): due/overdue and upcoming within 7 days. "Not done" // means still status='open' (see reminders.go status semantics). err = s.db.QueryRow(ctx, ` SELECT COUNT(*) FILTER (WHERE due_date <= now()), COUNT(*) FILTER (WHERE due_date > now() AND due_date <= now() + interval '7 days') FROM reminders WHERE tenant_id = $1 AND user_id = $2 AND status = 'open' `, tenantID, userID).Scan(&stats.RemindersDue, &stats.RemindersUpcoming7d) if err != nil { return nil, fmt.Errorf("storage: dashboard reminders: %w", err) } // Retention expiring within the next 30 days (active documents only). err = s.db.QueryRow(ctx, ` SELECT COUNT(*) FROM documents WHERE tenant_id = $1 AND deleted_at IS NULL AND retain_until IS NOT NULL AND retain_until >= current_date AND retain_until <= current_date + 30 `, tenantID).Scan(&stats.RetentionExpiring30d) if err != nil { return nil, fmt.Errorf("storage: dashboard retention expiring: %w", err) } // Documents by type — top 5 by count. Uses the structured document_types // entity via doc_type_id, falling back to the deprecated free-text doc_type // for Bestandsschutz, and "(ohne Typ)" when neither is set. rows, err := s.db.Query(ctx, ` SELECT COALESCE(dt.name, NULLIF(d.doc_type, ''), '(ohne Typ)') AS type_name, COUNT(*) AS cnt FROM documents d LEFT JOIN document_types dt ON dt.id = d.doc_type_id WHERE d.tenant_id = $1 AND d.deleted_at IS NULL GROUP BY type_name ORDER BY cnt DESC, type_name ASC LIMIT 5 `, tenantID) if err != nil { return nil, fmt.Errorf("storage: dashboard documents by type: %w", err) } defer rows.Close() for rows.Next() { var c DocumentTypeCount if err := rows.Scan(&c.DocumentTypeName, &c.Count); err != nil { return nil, fmt.Errorf("storage: scan documents by type: %w", err) } stats.DocumentsByType = append(stats.DocumentsByType, c) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("storage: dashboard documents by type rows: %w", err) } if stats.DocumentsByType == nil { stats.DocumentsByType = []DocumentTypeCount{} } return &stats, nil }