Files
archivdms/internal/storage/dashboard.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

115 lines
4.5 KiB
Go

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