feat: reporting dashboard — case stats, deadline compliance, workload, billing (P1)

Backend:
- ReportingService with aggregation queries (CTEs, FILTER clauses)
- 4 API endpoints: /api/reports/{cases,deadlines,workload,billing}
- Date range filtering via ?from=&to= query params

Frontend:
- /berichte page with 4 tabs: Akten, Fristen, Auslastung, Abrechnung
- recharts: bar/pie/line charts for all report types
- Date range picker, CSV export, print-friendly view
- Sidebar nav entry with BarChart3 icon

Also resolves merge conflicts between role-based, notification, and
audit trail branches, and adds missing TS types (AuditLogResponse,
Notification, NotificationPreferences).
This commit is contained in:
m
2026-03-30 11:24:45 +02:00
parent 8e65463130
commit fdef5af32e
17 changed files with 1812 additions and 124 deletions

View File

@@ -9,19 +9,11 @@ import (
type contextKey string
const (
<<<<<<< HEAD
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
||||||| 82878df
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
=======
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
>>>>>>> mai/pike/p0-role-based
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -41,7 +33,15 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}
<<<<<<< HEAD
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
@@ -62,15 +62,3 @@ func UserAgentFromContext(ctx context.Context) *string {
}
return nil
}
||||||| 82878df
=======
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
>>>>>>> mai/pike/p0-role-based

View File

@@ -35,36 +35,8 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx := ContextWithUserID(r.Context(), userID)
<<<<<<< HEAD
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
||||||| 82878df
// Resolve tenant and role from user_tenants
var membership struct {
TenantID uuid.UUID `db:"tenant_id"`
Role string `db:"role"`
}
err = m.db.GetContext(r.Context(), &membership,
"SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
=======
// Resolve tenant from user_tenants
var tenantID uuid.UUID
err = m.db.GetContext(r.Context(), &tenantID,
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, tenantID)
// Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For")
@@ -73,7 +45,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
>>>>>>> mai/knuth/p0-audit-trail-append
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -12,12 +12,8 @@ import (
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
<<<<<<< HEAD
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
||||||| 82878df
=======
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
>>>>>>> mai/pike/p0-role-based
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
@@ -46,33 +42,19 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return
}
<<<<<<< HEAD
// Verify user has access to this tenant
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if !hasAccess {
if role == "" {
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return
}
||||||| 82878df
=======
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
http.Error(w, "error checking tenant access", http.StatusInternalServerError)
return
}
if role == "" {
http.Error(w, "no access to this tenant", http.StatusForbidden)
return
}
>>>>>>> mai/pike/p0-role-based
tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role))

View File

@@ -10,49 +10,34 @@ import (
)
type mockTenantLookup struct {
<<<<<<< HEAD
tenantID *uuid.UUID
err error
hasAccess bool
accessErr error
||||||| 82878df
tenantID *uuid.UUID
err error
=======
tenantID *uuid.UUID
role string
err error
>>>>>>> mai/pike/p0-role-based
role string
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err
}
<<<<<<< HEAD
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr
}
||||||| 82878df
=======
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.role != "" {
return m.role, m.err
}
return "associate", m.err
if m.hasAccess {
return "associate", m.err
}
return "", m.err
}
>>>>>>> mai/pike/p0-role-based
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
<<<<<<< HEAD
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
||||||| 82878df
tr := NewTenantResolver(&mockTenantLookup{})
=======
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
>>>>>>> mai/pike/p0-role-based
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {